ваше сообщение коммита

This commit is contained in:
2025-09-24 13:05:20 +03:00
parent de0f8aecf2
commit 76cde4b53d
45 changed files with 2167 additions and 2854 deletions

105
SETUP_ACCESS_LEVELS.md Normal file
View File

@@ -0,0 +1,105 @@
# Настройка расширенной системы прав доступа
## Описание изменений
Добавлена расширенная система прав доступа с настраиваемыми порогами:
- **Read-Only (1+ токен)** - только просмотр данных
- **Editor (2+ токен)** - просмотр + редактирование + удаление
- **User (0 токенов)** - базовые права без изменений
## Новые поля в форме добавления токенов
На странице `/settings/security` в форме добавления токенов добавлены два новых поля:
1. **Минимум токенов для Read-Only доступа** (по умолчанию: 1)
2. **Минимум токенов для Editor доступа** (по умолчанию: 2)
## Применение изменений
### 1. Обновление базы данных
Выполните SQL скрипт для добавления новых полей:
```bash
# Подключитесь к базе данных PostgreSQL
psql -h localhost -U your_username -d your_database
# Выполните миграцию
\i backend/scripts/add_access_thresholds.sql
```
### 2. Перезапуск сервера
```bash
# В папке backend
yarn restart
# или
docker-compose restart backend
```
### 3. Перезапуск frontend
```bash
# В папке frontend
yarn dev
```
## Как использовать
### Для администраторов:
1. Перейдите на страницу `/settings/security`
2. В разделе "Токены аутентификации" нажмите "Подробнее"
3. При добавлении нового токена заполните:
- Название токена
- Адрес смарт-контракта
- Сеть блокчейна
- Минимальный баланс
- **Минимум токенов для Read-Only доступа**
- **Минимум токенов для Editor доступа**
### Для пользователей:
- Система автоматически определяет уровень доступа на основе количества токенов
- Отображается текущий уровень доступа с визуальными индикаторами
- UI автоматически адаптируется под уровень доступа
## Примеры настроек
### Стандартные настройки:
- Read-Only: 1 токен
- Editor: 2 токена
### Строгие настройки:
- Read-Only: 2 токена
- Editor: 5 токенов
### Либеральные настройки:
- Read-Only: 1 токен
- Editor: 1 токен (все пользователи с токенами могут редактировать)
## Технические детали
### Backend изменения:
- `auth-service.js` - добавлена функция `getUserAccessLevel()`
- `authTokenService.js` - поддержка новых полей
- `tokenBalanceService.js` - возврат порогов доступа
- `routes/settings.js` - API endpoint для новых полей
- `routes/auth.js` - новый endpoint `/access-level/:address`
### Frontend изменения:
- `useAuth.js` - поддержка уровней доступа
- `usePermissions.js` - новый composable для проверки прав
- `AuthTokensSettings.vue` - форма с новыми полями
- `SecuritySettingsView.vue` - использование новых прав доступа
### Новые поля в БД:
- `auth_tokens.readonly_threshold` - порог для Read-Only
- `auth_tokens.editor_threshold` - порог для Editor
## Обратная совместимость
- Все существующие токены получают значения по умолчанию (1 и 2)
- Старые проверки `isAdmin` продолжают работать
- Система автоматически мигрирует существующие данные

View File

@@ -946,4 +946,25 @@ router.get('/check-tokens/:address', async (req, res) => {
} }
}); });
// Маршрут для получения уровня доступа пользователя
router.get('/access-level/:address', async (req, res) => {
try {
const { address } = req.params;
// Получаем уровень доступа пользователя
const accessLevel = await authService.getUserAccessLevel(address);
res.json({
success: true,
data: accessLevel,
});
} catch (error) {
logger.error('Error getting user access level:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
});
}
});
module.exports = router; module.exports = router;

View File

@@ -21,20 +21,28 @@ const { requireAuth } = require('../middleware/auth');
// Проверка статуса подключения к Ollama // Проверка статуса подключения к Ollama
router.get('/status', requireAuth, async (req, res) => { router.get('/status', requireAuth, async (req, res) => {
try { try {
// Проверяем, что контейнер Ollama запущен const axios = require('axios');
const { stdout } = await execAsync('docker ps --filter "name=dapp-ollama" --format "{{.Names}}"'); const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
const isContainerRunning = stdout.trim() === 'dapp-ollama';
if (!isContainerRunning) { // Проверяем API Ollama через HTTP запрос
return res.json({ connected: false, error: 'Ollama container not running' });
}
// Проверяем API Ollama
try { try {
const { stdout: apiResponse } = await execAsync('docker exec dapp-ollama ollama list'); const response = await axios.get(`${ollamaUrl}/api/tags`, {
return res.json({ connected: true, message: 'Ollama is running' }); timeout: 5000 // 5 секунд таймаут
});
const models = response.data.models || [];
return res.json({
connected: true,
message: 'Ollama is running',
models: models.length,
availableModels: models.map(m => m.name)
});
} catch (apiError) { } catch (apiError) {
return res.json({ connected: false, error: 'Ollama API not responding' }); logger.error('Ollama API error:', apiError.message);
return res.json({
connected: false,
error: `Ollama API not responding: ${apiError.message}`
});
} }
} catch (error) { } catch (error) {
logger.error('Error checking Ollama status:', error); logger.error('Error checking Ollama status:', error);
@@ -45,21 +53,19 @@ router.get('/status', requireAuth, async (req, res) => {
// Получение списка установленных моделей // Получение списка установленных моделей
router.get('/models', requireAuth, async (req, res) => { router.get('/models', requireAuth, async (req, res) => {
try { try {
const { stdout } = await execAsync('docker exec dapp-ollama ollama list'); const axios = require('axios');
const lines = stdout.trim().split('\n').slice(1); // Пропускаем заголовок const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
const models = lines.map(line => { const response = await axios.get(`${ollamaUrl}/api/tags`, {
const parts = line.trim().split(/\s+/); timeout: 5000
if (parts.length >= 4) { });
return {
name: parts[0], const models = (response.data.models || []).map(model => ({
id: parts[1], name: model.name,
size: parseInt(parts[2]) || 0, id: model.model || model.name,
modified: parts.slice(3).join(' ') size: model.size || 0,
}; modified: model.modified_at || 'Unknown'
} }));
return null;
}).filter(model => model !== null);
res.json({ models }); res.json({ models });
} catch (error) { } catch (error) {

View File

@@ -177,7 +177,7 @@ router.get('/auth-tokens', async (req, res, next) => {
} }
const tokensResult = await db.getQuery()( const tokensResult = await db.getQuery()(
'SELECT id, min_balance, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens', 'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
[encryptionKey] [encryptionKey]
); );
const authTokens = tokensResult.rows; const authTokens = tokensResult.rows;
@@ -218,11 +218,11 @@ router.post('/auth-tokens', requireAdmin, async (req, res, next) => {
// Добавление/обновление одного токена // Добавление/обновление одного токена
router.post('/auth-token', requireAdmin, async (req, res, next) => { router.post('/auth-token', requireAdmin, async (req, res, next) => {
try { try {
const { name, address, network, minBalance } = req.body; const { name, address, network, minBalance, readonlyThreshold, editorThreshold } = req.body;
if (!name || !address || !network) { if (!name || !address || !network) {
return res.status(400).json({ success: false, error: 'name, address и network обязательны' }); return res.status(400).json({ success: false, error: 'name, address и network обязательны' });
} }
await authTokenService.upsertAuthToken({ name, address, network, minBalance }); await authTokenService.upsertAuthToken({ name, address, network, minBalance, readonlyThreshold, editorThreshold });
// Отправляем WebSocket уведомление о добавлении токена // Отправляем WebSocket уведомление о добавлении токена
try { try {

View File

@@ -35,8 +35,8 @@ async function upsertProviderSettings({ provider, api_key, base_url, selected_mo
const existing = await encryptedDb.getData(TABLE, { provider: provider }, 1); const existing = await encryptedDb.getData(TABLE, { provider: provider }, 1);
if (existing.length > 0) { if (existing.length > 0) {
// Обновляем существующую запись // Обновляем существующую запись по ID
return await encryptedDb.saveData(TABLE, data, { provider: provider }); return await encryptedDb.saveData(TABLE, data, { id: existing[0].id });
} else { } else {
// Создаем новую запись // Создаем новую запись
return await encryptedDb.saveData(TABLE, data); return await encryptedDb.saveData(TABLE, data);
@@ -120,29 +120,51 @@ async function getAllLLMModels() {
for (const provider of providers) { for (const provider of providers) {
if (provider.selected_model) { if (provider.selected_model) {
allModels.push({ // Фильтруем embedding модели - они не должны быть в списке LLM
id: provider.selected_model, const modelName = provider.selected_model.toLowerCase();
provider: provider.provider const isEmbeddingModel = modelName.includes('embed') ||
}); modelName.includes('embedding') ||
modelName.includes('bge') ||
modelName.includes('nomic') ||
modelName.includes('text-embedding') ||
modelName.includes('mxbai') ||
modelName.includes('sentence') ||
modelName.includes('ada-002') ||
modelName.includes('text-embedding-ada') ||
modelName.includes('text-embedding-3');
if (!isEmbeddingModel) {
allModels.push({
id: provider.selected_model,
provider: provider.provider
});
}
} }
} }
// Для Ollama проверяем реально установленные модели // Для Ollama проверяем реально установленные модели через HTTP API
try { try {
const { exec } = require('child_process'); const axios = require('axios');
const util = require('util'); const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
const execAsync = util.promisify(exec);
// Проверяем, какие модели установлены в Ollama const response = await axios.get(`${ollamaUrl}/api/tags`, {
const { stdout } = await execAsync('docker exec dapp-ollama ollama list'); timeout: 5000
const lines = stdout.trim().split('\n').slice(1); // Пропускаем заголовок });
for (const line of lines) { const models = response.data.models || [];
const parts = line.trim().split(/\s+/); for (const model of models) {
if (parts.length >= 2) { // Фильтруем embedding модели из Ollama
const modelName = parts[0]; const modelName = model.name.toLowerCase();
const isEmbeddingModel = modelName.includes('embed') ||
modelName.includes('embedding') ||
modelName.includes('bge') ||
modelName.includes('nomic') ||
modelName.includes('mxbai') ||
modelName.includes('sentence');
if (!isEmbeddingModel) {
allModels.push({ allModels.push({
id: modelName, id: model.name,
provider: 'ollama' provider: 'ollama'
}); });
} }
@@ -189,27 +211,23 @@ async function getAllEmbeddingModels() {
} }
} }
// Для Ollama проверяем реально установленные embedding модели // Для Ollama проверяем реально установленные embedding модели через HTTP API
try { try {
const { exec } = require('child_process'); const axios = require('axios');
const util = require('util'); const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
const execAsync = util.promisify(exec);
// Проверяем, какие embedding модели установлены в Ollama const response = await axios.get(`${ollamaUrl}/api/tags`, {
const { stdout } = await execAsync('docker exec dapp-ollama ollama list'); timeout: 5000
const lines = stdout.trim().split('\n').slice(1); // Пропускаем заголовок });
for (const line of lines) { const models = response.data.models || [];
const parts = line.trim().split(/\s+/); for (const model of models) {
if (parts.length >= 2) { // Проверяем, что это embedding модель
const modelName = parts[0]; if (model.name.includes('embed') || model.name.includes('bge') || model.name.includes('nomic')) {
// Проверяем, что это embedding модель allModels.push({
if (modelName.includes('embed') || modelName.includes('bge') || modelName.includes('nomic')) { id: model.name,
allModels.push({ provider: 'ollama'
id: modelName, });
provider: 'ollama'
});
}
} }
} }
} catch (ollamaError) { } catch (ollamaError) {

View File

@@ -466,6 +466,191 @@ class AuthService {
} }
} }
/**
* Определяет уровень доступа пользователя на основе количества токенов
* @param {string} address - Адрес кошелька
* @returns {Promise<{level: string, tokenCount: number, hasAccess: boolean}>}
*/
async getUserAccessLevel(address) {
if (!address) {
return { level: 'user', tokenCount: 0, hasAccess: false };
}
logger.info(`Checking access level for address: ${address}`);
try {
// Получаем токены из базы данных напрямую (как в checkAdminRole)
const fs = require('fs');
const path = require('path');
let encryptionKey = 'default-key';
try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key');
if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
}
} catch (keyError) {
console.error('Error reading encryption key:', keyError);
}
// Получаем токены из базы с расшифровкой
const tokensResult = await db.getQuery()(
'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
[encryptionKey]
);
const tokens = tokensResult.rows;
// Получаем RPC провайдеры
const rpcProvidersResult = await db.getQuery()(
'SELECT id, chain_id, created_at, updated_at, decrypt_text(network_id_encrypted, $1) as network_id, decrypt_text(rpc_url_encrypted, $1) as rpc_url FROM rpc_providers',
[encryptionKey]
);
const rpcProviders = rpcProvidersResult.rows;
const rpcMap = {};
for (const rpc of rpcProviders) {
rpcMap[rpc.network_id] = rpc.rpc_url;
}
// Получаем балансы токенов из блокчейна
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const tokenBalances = [];
for (const token of tokens) {
const rpcUrl = rpcMap[token.network];
if (!rpcUrl) continue;
try {
const provider = new ethers.JsonRpcProvider(rpcUrl);
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, provider);
// Получаем баланс с таймаутом
const balancePromise = tokenContract.balanceOf(address);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000) // Увеличиваем таймаут до 5 секунд
);
const rawBalance = await Promise.race([balancePromise, timeoutPromise]);
const balance = ethers.formatUnits(rawBalance, 18);
tokenBalances.push({
network: token.network,
tokenAddress: token.address,
tokenName: token.name,
symbol: '',
balance,
minBalance: token.min_balance,
readonlyThreshold: token.readonly_threshold || 1,
editorThreshold: token.editor_threshold || 2,
});
logger.info(`[getUserAccessLevel] Token balance for ${token.name} (${token.address}): ${balance}`);
} catch (error) {
logger.error(`[getUserAccessLevel] Error getting balance for ${token.name} (${token.address}):`, error.message);
// Добавляем токен с нулевым балансом
tokenBalances.push({
network: token.network,
tokenAddress: token.address,
tokenName: token.name,
symbol: '',
balance: '0',
minBalance: token.min_balance,
readonlyThreshold: token.readonly_threshold || 1,
editorThreshold: token.editor_threshold || 2,
});
}
}
if (!tokenBalances || !Array.isArray(tokenBalances)) {
logger.warn(`No token balances found for address: ${address}`);
return { level: 'user', tokenCount: 0, hasAccess: false };
}
// Подсчитываем сумму токенов с достаточным балансом
let validTokenCount = 0;
const validTokens = [];
for (const token of tokenBalances) {
const balance = parseFloat(token.balance || '0');
const minBalance = parseFloat(token.minBalance || '0');
if (balance >= minBalance) {
validTokenCount += balance; // Суммируем баланс токенов, а не количество сетей
validTokens.push({
name: token.name,
network: token.network,
balance: balance,
minBalance: minBalance
});
}
}
logger.info(`Token validation for ${address}:`, {
totalTokens: tokenBalances.length,
validTokens: validTokenCount,
validTokenDetails: validTokens
});
// Определяем уровень доступа на основе настроек токенов
let accessLevel = 'user';
let hasAccess = false;
// Получаем настройки порогов из токенов (используем самые низкие требования для максимального доступа)
let readonlyThreshold = Infinity;
let editorThreshold = Infinity;
if (tokenBalances.length > 0) {
// Находим самые низкие пороги среди всех токенов
for (const token of tokenBalances) {
const tokenReadonlyThreshold = token.readonlyThreshold || 1;
const tokenEditorThreshold = token.editorThreshold || 2;
if (tokenReadonlyThreshold < readonlyThreshold) {
readonlyThreshold = tokenReadonlyThreshold;
}
if (tokenEditorThreshold < editorThreshold) {
editorThreshold = tokenEditorThreshold;
}
}
// Если не нашли токены с порогами, используем дефолтные значения
if (readonlyThreshold === Infinity) readonlyThreshold = 1;
if (editorThreshold === Infinity) editorThreshold = 2;
logger.info(`[AuthService] Определены пороги доступа: readonly=${readonlyThreshold}, editor=${editorThreshold} (из ${tokenBalances.length} токенов)`);
} else {
readonlyThreshold = 1;
editorThreshold = 2;
}
if (validTokenCount >= editorThreshold) {
// Достаточно токенов для полных прав редактора
accessLevel = 'editor';
hasAccess = true;
} else if (validTokenCount > 0) {
// Есть токены, но недостаточно для редактора - права только на чтение
accessLevel = 'readonly';
hasAccess = true;
} else {
// Нет токенов - обычный пользователь
accessLevel = 'user';
hasAccess = false;
}
logger.info(`Access level determined for ${address}: ${accessLevel} (${validTokenCount} tokens)`);
return {
level: accessLevel,
tokenCount: validTokenCount,
hasAccess: hasAccess,
validTokens: validTokens
};
} catch (error) {
logger.error(`Error in getUserAccessLevel: ${error.message}`);
return { level: 'user', tokenCount: 0, hasAccess: false };
}
}
// Добавляем псевдоним функции checkAdminRole для обратной совместимости // Добавляем псевдоним функции checkAdminRole для обратной совместимости
async checkAdminTokens(address) { async checkAdminTokens(address) {
if (!address) return false; if (!address) return false;
@@ -473,9 +658,11 @@ class AuthService {
logger.info(`Checking admin tokens for address: ${address}`); logger.info(`Checking admin tokens for address: ${address}`);
try { try {
const isAdmin = await checkAdminRole(address); // Используем новую функцию для определения уровня доступа
const accessLevel = await this.getUserAccessLevel(address);
const isAdmin = accessLevel.hasAccess; // Любой доступ выше 'user' считается админским
// Обновляем роль пользователя в базе данных, если есть админские токены // Обновляем роль пользователя в базе данных
if (isAdmin) { if (isAdmin) {
try { try {
// Получаем ключ шифрования // Получаем ключ шифрования
@@ -503,16 +690,17 @@ class AuthService {
if (userResult.rows.length > 0) { if (userResult.rows.length > 0) {
const userId = userResult.rows[0].id; const userId = userResult.rows[0].id;
// Обновляем роль пользователя // Обновляем роль пользователя с учетом уровня доступа
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', ['admin', userId]); const role = accessLevel.level;
logger.info(`Updated user ${userId} role to admin based on token holdings`); await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [role, userId]);
logger.info(`Updated user ${userId} role to ${role} based on token holdings (${accessLevel.tokenCount} tokens)`);
} }
} catch (error) { } catch (error) {
logger.error('Error updating user role:', error); logger.error('Error updating user role:', error);
// Продолжаем выполнение, даже если обновление роли не удалось // Продолжаем выполнение, даже если обновление роли не удалось
} }
} else { } else {
// Если пользователь не является администратором, сбрасываем роль на "user", если она была "admin" // Если пользователь не имеет доступа, сбрасываем роль на "user"
try { try {
// Получаем ключ шифрования // Получаем ключ шифрования
const fs = require('fs'); const fs = require('fs');
@@ -536,10 +724,10 @@ class AuthService {
[address.toLowerCase(), encryptionKey] [address.toLowerCase(), encryptionKey]
); );
if (userResult.rows.length > 0 && userResult.rows[0].role === 'admin') { if (userResult.rows.length > 0 && userResult.rows[0].role !== 'user') {
const userId = userResult.rows[0].id; const userId = userResult.rows[0].id;
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', ['user', userId]); await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', ['user', userId]);
logger.info(`Reset user ${userId} role from admin to user (no tokens found)`); logger.info(`Reset user ${userId} role to user (no valid tokens found)`);
} }
} catch (error) { } catch (error) {
logger.error('Error updating user role:', error); logger.error('Error updating user role:', error);
@@ -594,20 +782,18 @@ class AuthService {
const address = user.address; const address = user.address;
const currentRole = user.role; const currentRole = user.role;
logger.info(`Rechecking admin status for user ${user.id} with address ${address}`); logger.info(`Rechecking access level for user ${user.id} with address ${address}`);
// Проверяем баланс токенов // Получаем новый уровень доступа
const isAdmin = await checkAdminRole(address); const accessLevel = await this.getUserAccessLevel(address);
const newRole = accessLevel.hasAccess ? accessLevel.level : 'user';
// Определяем новую роль
const newRole = isAdmin ? 'admin' : 'user';
// Обновляем роль только если она изменилась // Обновляем роль только если она изменилась
if (currentRole !== newRole) { if (currentRole !== newRole) {
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [newRole, user.id]); await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [newRole, user.id]);
logger.info(`Updated user ${user.id} role from ${currentRole} to ${newRole} (address: ${address})`); logger.info(`Updated user ${user.id} role from ${currentRole} to ${newRole} (address: ${address}, tokens: ${accessLevel.tokenCount})`);
} else { } else {
logger.info(`User ${user.id} role unchanged: ${currentRole} (address: ${address})`); logger.info(`User ${user.id} role unchanged: ${currentRole} (address: ${address}, tokens: ${accessLevel.tokenCount})`);
} }
} catch (userError) { } catch (userError) {

View File

@@ -27,13 +27,25 @@ async function saveAllAuthTokens(authTokens) {
name: token.name, name: token.name,
address: token.address, address: token.address,
network: token.network, network: token.network,
min_balance: token.minBalance == null ? 0 : Number(token.minBalance) min_balance: token.minBalance == null ? 0 : Number(token.minBalance),
readonly_threshold: token.readonlyThreshold == null ? 1 : Number(token.readonlyThreshold),
editor_threshold: token.editorThreshold == null ? 2 : Number(token.editorThreshold)
}); });
} }
} }
async function upsertAuthToken(token) { async function upsertAuthToken(token) {
console.log('[AuthTokenService] Получены данные токена:', token);
console.log('[AuthTokenService] token.readonlyThreshold:', token.readonlyThreshold, 'тип:', typeof token.readonlyThreshold);
console.log('[AuthTokenService] token.editorThreshold:', token.editorThreshold, 'тип:', typeof token.editorThreshold);
const minBalance = token.minBalance == null ? 0 : Number(token.minBalance); const minBalance = token.minBalance == null ? 0 : Number(token.minBalance);
const readonlyThreshold = (token.readonlyThreshold === null || token.readonlyThreshold === undefined || token.readonlyThreshold === '') ? 1 : Number(token.readonlyThreshold);
const editorThreshold = (token.editorThreshold === null || token.editorThreshold === undefined || token.editorThreshold === '') ? 2 : Number(token.editorThreshold);
console.log('[AuthTokenService] Вычисленные значения:');
console.log('[AuthTokenService] readonlyThreshold:', readonlyThreshold);
console.log('[AuthTokenService] editorThreshold:', editorThreshold);
// Проверяем, существует ли токен // Проверяем, существует ли токен
const existingTokens = await encryptedDb.getData('auth_tokens', { const existingTokens = await encryptedDb.getData('auth_tokens', {
@@ -45,7 +57,9 @@ async function upsertAuthToken(token) {
// Обновляем существующий токен // Обновляем существующий токен
await encryptedDb.saveData('auth_tokens', { await encryptedDb.saveData('auth_tokens', {
name: token.name, name: token.name,
min_balance: minBalance min_balance: minBalance,
readonly_threshold: readonlyThreshold,
editor_threshold: editorThreshold
}, { }, {
address: token.address, address: token.address,
network: token.network network: token.network
@@ -56,7 +70,9 @@ async function upsertAuthToken(token) {
name: token.name, name: token.name,
address: token.address, address: token.address,
network: token.network, network: token.network,
min_balance: minBalance min_balance: minBalance,
readonly_threshold: readonlyThreshold,
editor_threshold: editorThreshold
}); });
} }
} }

View File

@@ -289,12 +289,23 @@ class EncryptedDataService {
.map((key, index) => `${quoteReservedWord(key)} = ${allData[key]}`) .map((key, index) => `${quoteReservedWord(key)} = ${allData[key]}`)
.join(', '); .join(', ');
const whereClause = Object.keys(whereConditions) const whereClause = Object.keys(whereConditions)
.map((key, index) => `${quoteReservedWord(key)} = $${paramIndex + index}`) .map((key, index) => {
// Для WHERE условий используем зашифрованные имена колонок
const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`);
if (encryptedColumn) {
// Для зашифрованных колонок используем encrypt_text для сравнения
return `${quoteReservedWord(`${key}_encrypted`)} = encrypt_text($${paramIndex + index}, ${hasEncryptedFields ? '$1' : 'NULL'})`;
} else {
// Для незашифрованных колонок используем обычное сравнение
return `${quoteReservedWord(key)} = $${paramIndex + index}`;
}
})
.join(' AND '); .join(' AND ');
const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`; const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`;
const allParams = hasEncryptedFields ? [this.encryptionKey, ...Object.values(filteredData), ...Object.values(whereConditions)] : [...Object.values(filteredData), ...Object.values(whereConditions)]; const allParams = hasEncryptedFields ? [this.encryptionKey, ...Object.values(filteredData), ...Object.values(whereConditions)] : [...Object.values(filteredData), ...Object.values(whereConditions)];
const { rows } = await db.getQuery()(query, allParams); const { rows } = await db.getQuery()(query, allParams);
return rows[0]; return rows[0];
} else { } else {

View File

@@ -37,7 +37,7 @@ async function getUserTokenBalances(address) {
// Получаем токены и RPC с расшифровкой // Получаем токены и RPC с расшифровкой
const tokensResult = await db.getQuery()( const tokensResult = await db.getQuery()(
'SELECT id, min_balance, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens', 'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
[encryptionKey] [encryptionKey]
); );
const tokens = tokensResult.rows; const tokens = tokensResult.rows;
@@ -57,18 +57,42 @@ async function getUserTokenBalances(address) {
for (const token of tokens) { for (const token of tokens) {
const rpcUrl = rpcMap[token.network]; const rpcUrl = rpcMap[token.network];
if (!rpcUrl) continue; if (!rpcUrl) {
const provider = new ethers.JsonRpcProvider(rpcUrl); logger.warn(`[tokenBalanceService] RPC URL не найден для сети ${token.network}`);
continue;
}
// Создаем провайдер с таймаутом
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
polling: false,
staticNetwork: true
});
// Устанавливаем таймаут для запросов
provider._getConnection().timeout = 10000; // 10 секунд
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, provider); const tokenContract = new ethers.Contract(token.address, ERC20_ABI, provider);
let balance = '0'; let balance = '0';
try { try {
const rawBalance = await tokenContract.balanceOf(address); logger.info(`[tokenBalanceService] Получение баланса для ${token.name} (${token.address}) в сети ${token.network} для адреса ${address}`);
// Создаем промис с таймаутом
const balancePromise = tokenContract.balanceOf(address);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 10000)
);
const rawBalance = await Promise.race([balancePromise, timeoutPromise]);
balance = ethers.formatUnits(rawBalance, 18); balance = ethers.formatUnits(rawBalance, 18);
if (!balance || isNaN(Number(balance))) balance = '0'; if (!balance || isNaN(Number(balance))) balance = '0';
logger.info(`[tokenBalanceService] Баланс получен для ${token.name}: ${balance}`);
} catch (e) { } catch (e) {
logger.error( logger.error(
`[tokenBalanceService] Ошибка получения баланса для ${token.name} (${token.address}) в сети ${token.network}:`, `[tokenBalanceService] Ошибка получения баланса для ${token.name} (${token.address}) в сети ${token.network}:`,
e e.message || e
); );
balance = '0'; balance = '0';
} }
@@ -79,6 +103,8 @@ async function getUserTokenBalances(address) {
symbol: token.symbol || '', symbol: token.symbol || '',
balance, balance,
minBalance: token.min_balance, minBalance: token.min_balance,
readonlyThreshold: token.readonly_threshold || 1,
editorThreshold: token.editor_threshold || 2,
}); });
} }

View File

@@ -1,617 +0,0 @@
/* eslint-disable no-console */
const hre = require('hardhat');
const path = require('path');
const fs = require('fs');
// Подбираем безопасные gas/fee для разных сетей (включая L2)
async function getFeeOverrides(provider, { minPriorityGwei = 1n, minFeeGwei = 20n } = {}) {
const fee = await provider.getFeeData();
const overrides = {};
const minPriority = (await (async () => hre.ethers.parseUnits(minPriorityGwei.toString(), 'gwei'))());
const minFee = (await (async () => hre.ethers.parseUnits(minFeeGwei.toString(), 'gwei'))());
if (fee.maxFeePerGas) {
overrides.maxFeePerGas = fee.maxFeePerGas < minFee ? minFee : fee.maxFeePerGas;
overrides.maxPriorityFeePerGas = (fee.maxPriorityFeePerGas && fee.maxPriorityFeePerGas > 0n)
? fee.maxPriorityFeePerGas
: minPriority;
} else if (fee.gasPrice) {
overrides.gasPrice = fee.gasPrice < minFee ? minFee : fee.gasPrice;
}
return overrides;
}
async function deployInNetwork(rpcUrl, pk, salt, initCodeHash, targetDLENonce, dleInit) {
const { ethers } = hre;
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(pk, provider);
const net = await provider.getNetwork();
// DEBUG: базовая информация по сети
try {
const calcInitHash = ethers.keccak256(dleInit);
const saltLen = ethers.getBytes(salt).length;
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} rpc=${rpcUrl}`);
console.log(`[MULTI_DBG] wallet=${wallet.address} targetDLENonce=${targetDLENonce}`);
console.log(`[MULTI_DBG] saltLenBytes=${saltLen} salt=${salt}`);
console.log(`[MULTI_DBG] initCodeHash(provided)=${initCodeHash}`);
console.log(`[MULTI_DBG] initCodeHash(calculated)=${calcInitHash}`);
console.log(`[MULTI_DBG] dleInit.lenBytes=${ethers.getBytes(dleInit).length} head16=${dleInit.slice(0, 34)}...`);
} catch (e) {
console.log('[MULTI_DBG] precheck error', e?.message || e);
}
// 1) Выравнивание nonce до targetDLENonce нулевыми транзакциями (если нужно)
let current = await provider.getTransactionCount(wallet.address, 'pending');
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} current nonce=${current} target=${targetDLENonce}`);
if (current > targetDLENonce) {
throw new Error(`Current nonce ${current} > targetDLENonce ${targetDLENonce} on chainId=${Number(net.chainId)}`);
}
if (current < targetDLENonce) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} aligning nonce from ${current} to ${targetDLENonce} (${targetDLENonce - current} transactions needed)`);
// Используем burn address для более надежных транзакций
const burnAddress = "0x000000000000000000000000000000000000dEaD";
while (current < targetDLENonce) {
const overrides = await getFeeOverrides(provider);
let gasLimit = 21000; // минимальный gas для обычной транзакции
let sent = false;
let lastErr = null;
for (let attempt = 0; attempt < 3 && !sent; attempt++) {
try {
const txReq = {
to: burnAddress, // отправляем на burn address вместо своего адреса
value: 0n,
nonce: current,
gasLimit,
...overrides
};
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} sending filler tx nonce=${current} attempt=${attempt + 1}`);
const txFill = await wallet.sendTransaction(txReq);
await txFill.wait();
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} filler tx nonce=${current} confirmed`);
sent = true;
} catch (e) {
lastErr = e;
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} filler tx nonce=${current} attempt=${attempt + 1} failed: ${e?.message || e}`);
if (String(e?.message || '').toLowerCase().includes('intrinsic gas too low') && attempt < 2) {
gasLimit = 50000; // увеличиваем gas limit
continue;
}
if (String(e?.message || '').toLowerCase().includes('nonce too low') && attempt < 2) {
// Обновляем nonce и пробуем снова
current = await provider.getTransactionCount(wallet.address, 'pending');
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} updated nonce to ${current}`);
continue;
}
throw e;
}
}
if (!sent) {
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} failed to send filler tx for nonce=${current}`);
throw lastErr || new Error('filler tx failed');
}
current++;
}
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce alignment completed, current nonce=${current}`);
} else {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce already aligned at ${current}`);
}
// 2) Деплой DLE напрямую на согласованном nonce
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying DLE directly with nonce=${targetDLENonce}`);
const feeOverrides = await getFeeOverrides(provider);
let gasLimit;
try {
// Оцениваем газ для деплоя DLE
const est = await wallet.estimateGas({ data: dleInit, ...feeOverrides }).catch(() => null);
// Рассчитываем доступный gasLimit из баланса
const balance = await provider.getBalance(wallet.address, 'latest');
const effPrice = feeOverrides.maxFeePerGas || feeOverrides.gasPrice || 0n;
const reserve = hre.ethers.parseEther('0.005');
const maxByBalance = effPrice > 0n && balance > reserve ? (balance - reserve) / effPrice : 3_000_000n;
const fallbackGas = maxByBalance > 5_000_000n ? 5_000_000n : (maxByBalance < 2_500_000n ? 2_500_000n : maxByBalance);
gasLimit = est ? (est + est / 5n) : fallbackGas;
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} estGas=${est?.toString?.()||'null'} effGasPrice=${effPrice?.toString?.()||'0'} maxByBalance=${maxByBalance.toString()} chosenGasLimit=${gasLimit.toString()}`);
} catch (_) {
gasLimit = 3_000_000n;
}
// Вычисляем предсказанный адрес DLE
const predictedAddress = ethers.getCreateAddress({
from: wallet.address,
nonce: targetDLENonce
});
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} predicted DLE address=${predictedAddress}`);
// Проверяем, не развернут ли уже контракт
const existingCode = await provider.getCode(predictedAddress);
if (existingCode && existingCode !== '0x') {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE already exists at predictedAddress, skip deploy`);
return { address: predictedAddress, chainId: Number(net.chainId) };
}
// Деплоим DLE
let tx;
try {
tx = await wallet.sendTransaction({
data: dleInit,
nonce: targetDLENonce,
gasLimit,
...feeOverrides
});
} catch (e) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploy error(first): ${e?.message || e}`);
// Повторная попытка с обновленным nonce
const updatedNonce = await provider.getTransactionCount(wallet.address, 'pending');
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} retry deploy with nonce=${updatedNonce}`);
tx = await wallet.sendTransaction({
data: dleInit,
nonce: updatedNonce,
gasLimit,
...feeOverrides
});
}
const rc = await tx.wait();
const deployedAddress = rc.contractAddress || predictedAddress;
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE deployed at=${deployedAddress}`);
return { address: deployedAddress, chainId: Number(net.chainId) };
}
// Деплой модулей в одной сети
async function deployModulesInNetwork(rpcUrl, pk, dleAddress, params) {
const { ethers } = hre;
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(pk, provider);
const net = await provider.getNetwork();
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying modules...`);
const modules = {};
// Получаем начальный nonce для всех модулей
let currentNonce = await wallet.getNonce();
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} starting nonce for modules: ${currentNonce}`);
// Функция для безопасного деплоя с правильным nonce
async function deployWithNonce(contractFactory, args, moduleName) {
try {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying ${moduleName} with nonce: ${currentNonce}`);
// Проверяем, что nonce актуален
const actualNonce = await wallet.getNonce();
if (actualNonce > currentNonce) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch, updating from ${currentNonce} to ${actualNonce}`);
currentNonce = actualNonce;
}
const contract = await contractFactory.connect(wallet).deploy(...args);
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} ${moduleName} deployed at: ${address}`);
currentNonce++;
return address;
} catch (error) {
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} ${moduleName} deployment failed:`, error.message);
// Даже при ошибке увеличиваем nonce, чтобы не было конфликтов
currentNonce++;
return null;
}
}
// Деплой TreasuryModule
const TreasuryModule = await hre.ethers.getContractFactory('TreasuryModule');
modules.treasuryModule = await deployWithNonce(
TreasuryModule,
[dleAddress, Number(net.chainId), wallet.address], // _dleContract, _chainId, _emergencyAdmin
'TreasuryModule'
);
// Деплой TimelockModule
const TimelockModule = await hre.ethers.getContractFactory('TimelockModule');
modules.timelockModule = await deployWithNonce(
TimelockModule,
[dleAddress], // _dleContract
'TimelockModule'
);
// Деплой DLEReader
const DLEReader = await hre.ethers.getContractFactory('DLEReader');
modules.dleReader = await deployWithNonce(
DLEReader,
[dleAddress], // _dleContract
'DLEReader'
);
// Инициализация модулей в DLE
try {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing modules in DLE with nonce: ${currentNonce}`);
// Проверяем, что nonce актуален
const actualNonce = await wallet.getNonce();
if (actualNonce > currentNonce) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch before module init, updating from ${currentNonce} to ${actualNonce}`);
currentNonce = actualNonce;
}
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
// Проверяем, что все модули задеплоены
const treasuryAddress = modules.treasuryModule;
const timelockAddress = modules.timelockModule;
const readerAddress = modules.dleReader;
if (treasuryAddress && timelockAddress && readerAddress) {
// Инициализация базовых модулей
await dleContract.initializeBaseModules(treasuryAddress, timelockAddress, readerAddress);
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} base modules initialized`);
currentNonce++;
} else {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} skipping module initialization - not all modules deployed`);
}
} catch (error) {
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} module initialization failed:`, error.message);
// Даже при ошибке увеличиваем nonce
currentNonce++;
}
// Инициализация logoURI
try {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing logoURI with nonce: ${currentNonce}`);
// Проверяем, что nonce актуален
const actualNonce = await wallet.getNonce();
if (actualNonce > currentNonce) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch before logoURI init, updating from ${currentNonce} to ${actualNonce}`);
currentNonce = actualNonce;
}
// Используем логотип из параметров деплоя или fallback
const logoURL = params.logoURI || "https://via.placeholder.com/200x200/0066cc/ffffff?text=DLE";
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
await dleContract.initializeLogoURI(logoURL);
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} logoURI initialized: ${logoURL}`);
currentNonce++;
} catch (e) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} logoURI initialization failed: ${e.message}`);
// Fallback на базовый логотип
try {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} trying fallback logoURI with nonce: ${currentNonce}`);
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
await dleContract.initializeLogoURI("https://via.placeholder.com/200x200/0066cc/ffffff?text=DLE");
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} fallback logoURI initialized`);
currentNonce++;
} catch (fallbackError) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} fallback logoURI also failed: ${fallbackError.message}`);
// Даже при ошибке увеличиваем nonce
currentNonce++;
}
}
return modules;
}
// Деплой модулей во всех сетях
async function deployModulesInAllNetworks(networks, pk, dleAddress, params) {
const moduleResults = [];
for (let i = 0; i < networks.length; i++) {
const rpcUrl = networks[i];
console.log(`[MULTI_DBG] deploying modules to network ${i + 1}/${networks.length}: ${rpcUrl}`);
try {
const modules = await deployModulesInNetwork(rpcUrl, pk, dleAddress, params);
moduleResults.push(modules);
} catch (error) {
console.error(`[MULTI_DBG] Failed to deploy modules in network ${i + 1}:`, error.message);
moduleResults.push({ error: error.message });
}
}
return moduleResults;
}
// Верификация контрактов в одной сети
async function verifyContractsInNetwork(rpcUrl, pk, dleAddress, modules, params) {
const { ethers } = hre;
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(pk, provider);
const net = await provider.getNetwork();
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} starting verification...`);
const verification = {};
try {
// Верификация DLE
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying DLE...`);
await hre.run("verify:verify", {
address: dleAddress,
constructorArguments: [
{
name: params.name || '',
symbol: params.symbol || '',
location: params.location || '',
coordinates: params.coordinates || '',
jurisdiction: params.jurisdiction || 0,
oktmo: params.oktmo || '',
okvedCodes: params.okvedCodes || [],
kpp: params.kpp ? BigInt(params.kpp) : 0n,
quorumPercentage: params.quorumPercentage || 51,
initialPartners: params.initialPartners || [],
initialAmounts: (params.initialAmounts || []).map(amount => BigInt(amount)),
supportedChainIds: (params.supportedChainIds || []).map(id => BigInt(id))
},
BigInt(params.currentChainId || params.supportedChainIds?.[0] || 1),
params.initializer || params.initialPartners?.[0] || "0x0000000000000000000000000000000000000000"
],
});
verification.dle = 'success';
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification successful`);
} catch (error) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification failed: ${error.message}`);
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification error details:`, error);
verification.dle = 'failed';
}
// Верификация модулей
if (modules && !modules.error) {
try {
// Верификация TreasuryModule
if (modules.treasuryModule) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying TreasuryModule...`);
await hre.run("verify:verify", {
address: modules.treasuryModule,
constructorArguments: [
dleAddress, // _dleContract
Number(net.chainId), // _chainId
wallet.address // _emergencyAdmin
],
});
verification.treasuryModule = 'success';
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TreasuryModule verification successful`);
}
} catch (error) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TreasuryModule verification failed: ${error.message}`);
verification.treasuryModule = 'failed';
}
try {
// Верификация TimelockModule
if (modules.timelockModule) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying TimelockModule...`);
await hre.run("verify:verify", {
address: modules.timelockModule,
constructorArguments: [
dleAddress // _dleContract
],
});
verification.timelockModule = 'success';
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TimelockModule verification successful`);
}
} catch (error) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TimelockModule verification failed: ${error.message}`);
verification.timelockModule = 'failed';
}
try {
// Верификация DLEReader
if (modules.dleReader) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying DLEReader...`);
await hre.run("verify:verify", {
address: modules.dleReader,
constructorArguments: [
dleAddress // _dleContract
],
});
verification.dleReader = 'success';
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLEReader verification successful`);
}
} catch (error) {
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLEReader verification failed: ${error.message}`);
verification.dleReader = 'failed';
}
}
return verification;
}
// Верификация контрактов во всех сетях
async function verifyContractsInAllNetworks(networks, pk, dleAddress, moduleResults, params) {
const verificationResults = [];
for (let i = 0; i < networks.length; i++) {
const rpcUrl = networks[i];
console.log(`[MULTI_DBG] verifying contracts in network ${i + 1}/${networks.length}: ${rpcUrl}`);
try {
const verification = await verifyContractsInNetwork(rpcUrl, pk, dleAddress, moduleResults[i], params);
verificationResults.push(verification);
} catch (error) {
console.error(`[MULTI_DBG] Failed to verify contracts in network ${i + 1}:`, error.message);
verificationResults.push({ error: error.message });
}
}
return verificationResults;
}
async function main() {
const { ethers } = hre;
// Загружаем параметры из файла
const paramsPath = path.join(__dirname, './current-params.json');
if (!fs.existsSync(paramsPath)) {
throw new Error('Файл параметров не найден: ' + paramsPath);
}
const params = JSON.parse(fs.readFileSync(paramsPath, 'utf8'));
console.log('[MULTI_DBG] Загружены параметры:', {
name: params.name,
symbol: params.symbol,
supportedChainIds: params.supportedChainIds,
CREATE2_SALT: params.CREATE2_SALT
});
const pk = process.env.PRIVATE_KEY;
const salt = params.CREATE2_SALT;
const networks = params.rpcUrls || [];
if (!pk) throw new Error('Env: PRIVATE_KEY');
if (!salt) throw new Error('CREATE2_SALT not found in params');
if (networks.length === 0) throw new Error('RPC URLs not found in params');
// Prepare init code once
const DLE = await hre.ethers.getContractFactory('DLE');
const dleConfig = {
name: params.name || '',
symbol: params.symbol || '',
location: params.location || '',
coordinates: params.coordinates || '',
jurisdiction: params.jurisdiction || 0,
oktmo: params.oktmo || '',
okvedCodes: params.okvedCodes || [],
kpp: params.kpp ? BigInt(params.kpp) : 0n,
quorumPercentage: params.quorumPercentage || 51,
initialPartners: params.initialPartners || [],
initialAmounts: (params.initialAmounts || []).map(amount => BigInt(amount)),
supportedChainIds: (params.supportedChainIds || []).map(id => BigInt(id))
};
const deployTx = await DLE.getDeployTransaction(dleConfig, BigInt(params.currentChainId || params.supportedChainIds?.[0] || 1), params.initializer || params.initialPartners?.[0] || "0x0000000000000000000000000000000000000000");
const dleInit = deployTx.data;
const initCodeHash = ethers.keccak256(dleInit);
// DEBUG: глобальные значения
try {
const saltLen = ethers.getBytes(salt).length;
console.log(`[MULTI_DBG] GLOBAL saltLenBytes=${saltLen} salt=${salt}`);
console.log(`[MULTI_DBG] GLOBAL initCodeHash(calculated)=${initCodeHash}`);
console.log(`[MULTI_DBG] GLOBAL dleInit.lenBytes=${ethers.getBytes(dleInit).length} head16=${dleInit.slice(0, 34)}...`);
} catch (e) {
console.log('[MULTI_DBG] GLOBAL precheck error', e?.message || e);
}
// Подготовим провайдеры и вычислим общий nonce для DLE
const providers = networks.map(u => new hre.ethers.JsonRpcProvider(u));
const wallets = providers.map(p => new hre.ethers.Wallet(pk, p));
const nonces = [];
for (let i = 0; i < providers.length; i++) {
const n = await providers[i].getTransactionCount(wallets[i].address, 'pending');
nonces.push(n);
}
const targetDLENonce = Math.max(...nonces);
console.log(`[MULTI_DBG] nonces=${JSON.stringify(nonces)} targetDLENonce=${targetDLENonce}`);
const results = [];
for (let i = 0; i < networks.length; i++) {
const rpcUrl = networks[i];
console.log(`[MULTI_DBG] deploying to network ${i + 1}/${networks.length}: ${rpcUrl}`);
const r = await deployInNetwork(rpcUrl, pk, salt, initCodeHash, targetDLENonce, dleInit);
results.push({ rpcUrl, ...r });
}
// Проверяем, что все адреса одинаковые
const addresses = results.map(r => r.address);
const uniqueAddresses = [...new Set(addresses)];
if (uniqueAddresses.length > 1) {
console.error('[MULTI_DBG] ERROR: DLE addresses are different across networks!');
console.error('[MULTI_DBG] addresses:', uniqueAddresses);
throw new Error('Nonce alignment failed - addresses are different');
}
console.log('[MULTI_DBG] SUCCESS: All DLE addresses are identical:', uniqueAddresses[0]);
// Деплой модулей во всех сетях
console.log('[MULTI_DBG] Starting module deployment...');
const moduleResults = await deployModulesInAllNetworks(networks, pk, uniqueAddresses[0], params);
// Верификация контрактов
console.log('[MULTI_DBG] Starting contract verification...');
const verificationResults = await verifyContractsInAllNetworks(networks, pk, uniqueAddresses[0], moduleResults, params);
// Объединяем результаты
const finalResults = results.map((result, index) => ({
...result,
modules: moduleResults[index] || {},
verification: verificationResults[index] || {}
}));
console.log('MULTICHAIN_DEPLOY_RESULT', JSON.stringify(finalResults));
// Сохраняем информацию о модулях в отдельный файл для каждого DLE
// Добавляем информацию о сетях (chainId, rpcUrl)
const modulesInfo = {
dleAddress: uniqueAddresses[0],
networks: networks.map((rpcUrl, index) => ({
rpcUrl: rpcUrl,
chainId: null, // Будет заполнено ниже
networkName: null // Будет заполнено ниже
})),
modules: moduleResults,
verification: verificationResults,
deployTimestamp: new Date().toISOString()
};
// Получаем chainId для каждой сети
for (let i = 0; i < networks.length; i++) {
try {
const provider = new hre.ethers.JsonRpcProvider(networks[i]);
const network = await provider.getNetwork();
modulesInfo.networks[i].chainId = Number(network.chainId);
// Определяем название сети по chainId
const networkNames = {
1: 'Ethereum Mainnet',
5: 'Goerli',
11155111: 'Sepolia',
137: 'Polygon Mainnet',
80001: 'Mumbai',
56: 'BSC Mainnet',
97: 'BSC Testnet',
42161: 'Arbitrum One',
421614: 'Arbitrum Sepolia',
10: 'Optimism',
11155420: 'Optimism Sepolia',
8453: 'Base',
84532: 'Base Sepolia'
};
modulesInfo.networks[i].networkName = networkNames[Number(network.chainId)] || `Chain ID ${Number(network.chainId)}`;
console.log(`[MULTI_DBG] Сеть ${i + 1}: chainId=${Number(network.chainId)}, name=${modulesInfo.networks[i].networkName}`);
} catch (error) {
console.error(`[MULTI_DBG] Ошибка получения chainId для сети ${i + 1}:`, error.message);
modulesInfo.networks[i].chainId = null;
modulesInfo.networks[i].networkName = `Сеть ${i + 1}`;
}
}
// Создаем директорию temp если её нет
const tempDir = path.join(__dirname, '../temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const deployResultPath = path.join(tempDir, `modules-${uniqueAddresses[0].toLowerCase()}.json`);
fs.writeFileSync(deployResultPath, JSON.stringify(modulesInfo, null, 2));
console.log(`[MULTI_DBG] Modules info saved to: ${deployResultPath}`);
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -1,452 +0,0 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const express = require('express');
const router = express.Router();
const dleV2Service = require('../services/dleV2Service');
const logger = require('../utils/logger');
const auth = require('../middleware/auth');
const path = require('path');
const fs = require('fs');
const ethers = require('ethers'); // Added ethers for private key validation
const create2 = require('../utils/create2');
const verificationStore = require('../services/verificationStore');
const etherscanV2 = require('../services/etherscanV2VerificationService');
/**
* @route POST /api/dle-v2
* @desc Создать новое DLE v2 (Digital Legal Entity)
* @access Private (только для авторизованных пользователей с ролью admin)
*/
router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res, next) => {
try {
const dleParams = req.body;
logger.info('Получен запрос на создание DLE v2:', dleParams);
// Если параметр initialPartners не был передан явно, используем адрес авторизованного пользователя
if (!dleParams.initialPartners || dleParams.initialPartners.length === 0) {
// Проверяем, есть ли в сессии адрес кошелька пользователя
if (!req.user || !req.user.walletAddress) {
return res.status(400).json({
success: false,
message: 'Не указан адрес кошелька пользователя или партнеров для распределения токенов'
});
}
// Используем адрес авторизованного пользователя
dleParams.initialPartners = [req.user.address || req.user.walletAddress];
// Если суммы не указаны, используем значение по умолчанию (100% токенов)
if (!dleParams.initialAmounts || dleParams.initialAmounts.length === 0) {
dleParams.initialAmounts = ['1000000000000000000000000']; // 1,000,000 токенов
}
}
// Создаем DLE v2
const result = await dleV2Service.createDLE(dleParams);
logger.info('DLE v2 успешно создано:', result);
res.json({
success: true,
message: 'DLE v2 успешно создано',
data: result.data
});
} catch (error) {
logger.error('Ошибка при создании DLE v2:', error);
res.status(500).json({
success: false,
message: error.message || 'Произошла ошибка при создании DLE v2'
});
}
});
/**
* @route GET /api/dle-v2
* @desc Получить список всех DLE v2
* @access Public (доступно всем пользователям)
*/
router.get('/', async (req, res, next) => {
try {
const dles = dleV2Service.getAllDLEs();
res.json({
success: true,
data: dles
});
} catch (error) {
logger.error('Ошибка при получении списка DLE v2:', error);
res.status(500).json({
success: false,
message: error.message || 'Произошла ошибка при получении списка DLE v2'
});
}
});
/**
* @route GET /api/dle-v2/default-params
* @desc Получить параметры по умолчанию для создания DLE v2
* @access Private
*/
router.get('/default-params', auth.requireAuth, async (req, res, next) => {
try {
const defaultParams = {
name: '',
symbol: '',
location: '',
coordinates: '',
jurisdiction: 1,
oktmo: 45000000000,
okvedCodes: [],
kpp: 770101001,
quorumPercentage: 51,
initialPartners: [],
initialAmounts: [],
supportedChainIds: [1, 137, 56, 42161],
currentChainId: 1
};
res.json({
success: true,
data: defaultParams
});
} catch (error) {
logger.error('Ошибка при получении параметров по умолчанию:', error);
res.status(500).json({
success: false,
message: error.message || 'Произошла ошибка при получении параметров по умолчанию'
});
}
});
/**
* @route DELETE /api/dle-v2/:dleAddress
* @desc Удалить DLE v2 по адресу
* @access Private (только для авторизованных пользователей с ролью admin)
*/
router.delete('/:dleAddress', auth.requireAuth, auth.requireAdmin, async (req, res, next) => {
try {
const { dleAddress } = req.params;
logger.info(`Получен запрос на удаление DLE v2 с адресом: ${dleAddress}`);
// Проверяем существование DLE v2 в директории contracts-data/dles
const dlesDir = path.join(__dirname, '../contracts-data/dles');
const files = fs.readdirSync(dlesDir);
let fileToDelete = null;
// Находим файл, содержащий указанный адрес DLE
for (const file of files) {
if (file.includes('dle-v2-') && file.endsWith('.json')) {
const filePath = path.join(dlesDir, file);
if (fs.statSync(filePath).isFile()) {
try {
const dleData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (dleData.dleAddress && dleData.dleAddress.toLowerCase() === dleAddress.toLowerCase()) {
fileToDelete = filePath;
break;
}
} catch (err) {
logger.error(`Ошибка при чтении файла ${file}:`, err);
}
}
}
}
if (!fileToDelete) {
return res.status(404).json({
success: false,
message: `DLE v2 с адресом ${dleAddress} не найдено`
});
}
// Удаляем файл
fs.unlinkSync(fileToDelete);
logger.info(`DLE v2 с адресом ${dleAddress} успешно удалено`);
res.json({
success: true,
message: `DLE v2 с адресом ${dleAddress} успешно удалено`
});
} catch (error) {
logger.error('Ошибка при удалении DLE v2:', error);
res.status(500).json({
success: false,
message: error.message || 'Произошла ошибка при удалении DLE v2'
});
}
});
/**
* @route GET /api/dle-v2/check-admin-tokens
* @desc Проверить баланс админских токенов для адреса
* @access Public
*/
router.get('/check-admin-tokens', async (req, res, next) => {
try {
const { address } = req.query;
if (!address) {
return res.status(400).json({
success: false,
message: 'Адрес кошелька не передан'
});
}
// Проверяем баланс токенов
const { checkAdminRole } = require('../services/admin-role');
const isAdmin = await checkAdminRole(address);
res.json({
success: true,
data: {
isAdmin: isAdmin,
address: address,
message: isAdmin ? 'Админские токены найдены' : 'Админские токены не найдены'
}
});
} catch (error) {
logger.error('Ошибка при проверке админских токенов:', error);
res.status(500).json({
success: false,
message: error.message || 'Произошла ошибка при проверке админских токенов'
});
}
});
/**
* @route POST /api/dle-v2/validate-private-key
* @desc Валидировать приватный ключ и получить адрес кошелька
* @access Public
*/
router.post('/validate-private-key', async (req, res, next) => {
try {
const { privateKey } = req.body;
if (!privateKey) {
return res.status(400).json({
success: false,
message: 'Приватный ключ не передан'
});
}
// Логируем входящий ключ (только для отладки)
logger.info('Получен приватный ключ для валидации:', privateKey);
logger.info('Длина входящего ключа:', privateKey.length);
logger.info('Тип входящего ключа:', typeof privateKey);
logger.info('Полный объект запроса:', JSON.stringify(req.body));
try {
// Очищаем ключ от префикса 0x если есть
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey;
// Логируем очищенный ключ (только для отладки)
logger.info('Очищенный ключ:', cleanKey);
logger.info('Длина очищенного ключа:', cleanKey.length);
// Проверяем длину и формат (64 символа в hex)
if (cleanKey.length !== 64 || !/^[a-fA-F0-9]+$/.test(cleanKey)) {
logger.error('Некорректный формат ключа. Длина:', cleanKey.length, 'Формат:', /^[a-fA-F0-9]+$/.test(cleanKey));
return res.status(400).json({
success: false,
message: 'Некорректный формат приватного ключа'
});
}
// Генерируем адрес из приватного ключа
const wallet = new ethers.Wallet('0x' + cleanKey);
const address = wallet.address;
// Логируем сгенерированный адрес
logger.info('Сгенерированный адрес из приватного ключа:', address);
res.json({
success: true,
data: {
isValid: true,
address: address,
error: null
}
});
} catch (error) {
logger.error('Ошибка при генерации адреса из приватного ключа:', error);
res.status(400).json({
success: false,
message: 'Некорректный приватный ключ'
});
}
} catch (error) {
logger.error('Ошибка при валидации приватного ключа:', error);
res.status(500).json({
success: false,
message: error.message || 'Произошла ошибка при валидации приватного ключа'
});
}
});
module.exports = router;
/**
* Дополнительные маршруты (подключаются из app.js)
*/
// Сохранить GUID верификации (если нужно отдельным вызовом)
router.post('/verify/save-guid', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { address, chainId, guid } = req.body || {};
if (!address || !chainId || !guid) return res.status(400).json({ success: false, message: 'address, chainId, guid обязательны' });
const data = verificationStore.updateChain(address, chainId, { guid, status: 'submitted' });
return res.json({ success: true, data });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// Получить статусы верификации по адресу DLE
router.get('/verify/status/:address', auth.requireAuth, async (req, res) => {
try {
const { address } = req.params;
const data = verificationStore.read(address);
return res.json({ success: true, data });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// Обновить статусы верификации, опросив Etherscan V2
router.post('/verify/refresh/:address', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { address } = req.params;
let { etherscanApiKey } = req.body || {};
if (!etherscanApiKey) {
try {
const { getSecret } = require('../services/secretStore');
etherscanApiKey = await getSecret('ETHERSCAN_V2_API_KEY');
} catch(_) {}
}
const data = verificationStore.read(address);
if (!data || !data.chains) return res.json({ success: true, data });
// Если guid отсутствует или ранее была ошибка chainid — попробуем автоматически переотправить верификацию (resubmit)
const needResubmit = Object.values(data.chains).some(c => !c.guid || /Missing or unsupported chainid/i.test(c.status || ''));
if (needResubmit && etherscanApiKey) {
// Найти карточку DLE
const list = dleV2Service.getAllDLEs();
const card = list.find(x => x?.dleAddress && x.dleAddress.toLowerCase() === address.toLowerCase());
if (card) {
const deployParams = {
name: card.name,
symbol: card.symbol,
location: card.location,
coordinates: card.coordinates,
jurisdiction: card.jurisdiction,
oktmo: card.oktmo,
okvedCodes: Array.isArray(card.okvedCodes) ? card.okvedCodes : [],
kpp: card.kpp,
quorumPercentage: card.quorumPercentage,
initialPartners: Array.isArray(card.initialPartners) ? card.initialPartners : [],
initialAmounts: Array.isArray(card.initialAmounts) ? card.initialAmounts : [],
supportedChainIds: Array.isArray(card.networks) ? card.networks.map(n => n.chainId).filter(Boolean) : (card.governanceSettings?.supportedChainIds || []),
currentChainId: card.governanceSettings?.currentChainId || (Array.isArray(card.networks) && card.networks[0]?.chainId) || 1
};
const deployResult = { success: true, data: { dleAddress: card.dleAddress, networks: card.networks || [] } };
try {
await dleV2Service.autoVerifyAcrossChains({ deployParams, deployResult, apiKey: etherscanApiKey });
} catch (_) {}
}
}
// Далее — обычный опрос по имеющимся guid
const latest = verificationStore.read(address);
const chains = Object.values(latest.chains);
for (const c of chains) {
if (!c.guid || !c.chainId) continue;
try {
const st = await etherscanV2.checkStatus(c.chainId, c.guid, etherscanApiKey);
verificationStore.updateChain(address, c.chainId, { status: st?.result || st?.message || 'unknown' });
} catch (e) {
verificationStore.updateChain(address, c.chainId, { status: `error: ${e.message}` });
}
}
const updated = verificationStore.read(address);
return res.json({ success: true, data: updated });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// Повторно отправить верификацию на Etherscan V2 для уже созданного DLE
router.post('/verify/resubmit/:address', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { address } = req.params;
const { etherscanApiKey } = req.body || {};
if (!etherscanApiKey && !process.env.ETHERSCAN_API_KEY) {
return res.status(400).json({ success: false, message: 'etherscanApiKey обязателен' });
}
// Найти карточку DLE по адресу
const list = dleV2Service.getAllDLEs();
const card = list.find(x => x?.dleAddress && x.dleAddress.toLowerCase() === address.toLowerCase());
if (!card) return res.status(404).json({ success: false, message: 'Карточка DLE не найдена' });
// Сформировать deployParams из карточки
const deployParams = {
name: card.name,
symbol: card.symbol,
location: card.location,
coordinates: card.coordinates,
jurisdiction: card.jurisdiction,
oktmo: card.oktmo,
okvedCodes: Array.isArray(card.okvedCodes) ? card.okvedCodes : [],
kpp: card.kpp,
quorumPercentage: card.quorumPercentage,
initialPartners: Array.isArray(card.initialPartners) ? card.initialPartners : [],
initialAmounts: Array.isArray(card.initialAmounts) ? card.initialAmounts : [],
supportedChainIds: Array.isArray(card.networks) ? card.networks.map(n => n.chainId).filter(Boolean) : (card.governanceSettings?.supportedChainIds || []),
currentChainId: card.governanceSettings?.currentChainId || (Array.isArray(card.networks) && card.networks[0]?.chainId) || 1
};
// Сформировать deployResult из карточки
const deployResult = { success: true, data: { dleAddress: card.dleAddress, networks: card.networks || [] } };
await dleV2Service.autoVerifyAcrossChains({ deployParams, deployResult, apiKey: etherscanApiKey });
const updated = verificationStore.read(address);
return res.json({ success: true, data: updated });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// Предварительная проверка балансов во всех выбранных сетях
router.post('/precheck', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { supportedChainIds, privateKey } = req.body || {};
if (!privateKey) return res.status(400).json({ success: false, message: 'Приватный ключ не передан' });
if (!Array.isArray(supportedChainIds) || supportedChainIds.length === 0) {
return res.status(400).json({ success: false, message: 'Не переданы сети для проверки' });
}
const result = await dleV2Service.checkBalances(supportedChainIds, privateKey);
return res.json({ success: true, data: result });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});

View File

@@ -1,971 +0,0 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const { ethers } = require('ethers');
const logger = require('../utils/logger');
const { getRpcUrlByChainId } = require('./rpcProviderService');
const etherscanV2 = require('./etherscanV2VerificationService');
const verificationStore = require('./verificationStore');
/**
* Сервис для управления DLE v2 (Digital Legal Entity)
* Современный подход с единым контрактом
*/
class DLEV2Service {
/**
* Создает новое DLE v2 с заданными параметрами
* @param {Object} dleParams - Параметры DLE
* @returns {Promise<Object>} - Результат создания DLE
*/
async createDLE(dleParams) {
console.log("🔥 [DLEV2-SERVICE] ФУНКЦИЯ createDLE ВЫЗВАНА!");
logger.info("🚀 DEBUG: ВХОДИМ В createDLE ФУНКЦИЮ");
let paramsFile = null;
let tempParamsFile = null;
try {
logger.info('Начало создания DLE v2 с параметрами:', dleParams);
// Валидация входных данных
this.validateDLEParams(dleParams);
// Подготовка параметров для деплоя
const deployParams = this.prepareDeployParams(dleParams);
// Вычисляем адрес инициализатора (инициализатором является деплоер из переданного приватного ключа)
try {
const normalizedPk = dleParams.privateKey?.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`;
const initializerAddress = new ethers.Wallet(normalizedPk).address;
deployParams.initializerAddress = initializerAddress;
} catch (e) {
logger.warn('Не удалось вычислить initializerAddress из приватного ключа:', e.message);
}
// Генерируем одноразовый CREATE2_SALT и сохраняем его с уникальным ключом в secrets
const { createAndStoreNewCreate2Salt } = require('./secretStore');
const { salt: create2Salt, key: saltKey } = await createAndStoreNewCreate2Salt({ label: deployParams.name || 'DLEv2' });
logger.info(`CREATE2_SALT создан и сохранён: key=${saltKey}`);
// Сохраняем параметры во временный файл
paramsFile = this.saveParamsToFile(deployParams);
// Копируем параметры во временный файл с предсказуемым именем
tempParamsFile = path.join(__dirname, '../scripts/deploy/current-params.json');
const deployDir = path.dirname(tempParamsFile);
if (!fs.existsSync(deployDir)) {
fs.mkdirSync(deployDir, { recursive: true });
}
fs.copyFileSync(paramsFile, tempParamsFile);
// Готовим RPC для всех выбранных сетей
const rpcUrls = [];
for (const cid of deployParams.supportedChainIds) {
logger.info(`Поиск RPC URL для chain_id: ${cid}`);
const ru = await getRpcUrlByChainId(cid);
if (!ru) {
throw new Error(`RPC URL для сети с chain_id ${cid} не найден в базе данных`);
}
rpcUrls.push(ru);
}
// Добавляем CREATE2_SALT, RPC_URLS и initializer в файл параметров
const currentParams = JSON.parse(fs.readFileSync(tempParamsFile, 'utf8'));
// Копируем все параметры из deployParams
Object.assign(currentParams, deployParams);
currentParams.CREATE2_SALT = create2Salt;
currentParams.rpcUrls = rpcUrls;
currentParams.currentChainId = deployParams.currentChainId || deployParams.supportedChainIds[0];
const { ethers } = require('ethers');
currentParams.initializer = dleParams.privateKey ? new ethers.Wallet(dleParams.privateKey.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`).address : "0x0000000000000000000000000000000000000000";
fs.writeFileSync(tempParamsFile, JSON.stringify(currentParams, null, 2));
logger.info(`Файл параметров скопирован и обновлен с CREATE2_SALT`);
// Лёгкая проверка баланса в первой сети
{
const { ethers } = require('ethers');
const provider = new ethers.JsonRpcProvider(rpcUrls[0]);
if (dleParams.privateKey) {
const pk = dleParams.privateKey.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`;
const walletAddress = new ethers.Wallet(pk, provider).address;
const balance = await provider.getBalance(walletAddress);
const minBalance = ethers.parseEther("0.00001");
logger.info(`Баланс кошелька ${walletAddress}: ${ethers.formatEther(balance)} ETH`);
if (balance < minBalance) {
throw new Error(`Недостаточно ETH для деплоя в ${deployParams.supportedChainIds[0]}. Баланс: ${ethers.formatEther(balance)} ETH`);
}
}
}
if (!dleParams.privateKey) {
throw new Error('Приватный ключ для деплоя не передан');
}
// Сначала компилируем контракты
logger.info("🔨 Компилируем контракты перед вычислением INIT_CODE_HASH...");
try {
const { spawn } = require('child_process');
await new Promise((resolve, reject) => {
const compile = spawn('npx', ['hardhat', 'compile'], {
cwd: process.cwd(),
stdio: 'inherit'
});
compile.on('close', (code) => {
if (code === 0) {
logger.info('✅ Контракты скомпилированы успешно');
resolve();
} else {
logger.warn(`⚠️ Компиляция завершилась с кодом: ${code}`);
resolve(); // Продолжаем даже при ошибке компиляции
}
});
compile.on('error', (error) => {
logger.warn('⚠️ Ошибка компиляции:', error.message);
resolve(); // Продолжаем даже при ошибке
});
});
} catch (compileError) {
logger.warn('⚠️ Ошибка компиляции:', compileError.message);
}
// INIT_CODE_HASH будет вычислен в deploy-multichain.js
// Factory больше не используется - деплой DLE напрямую
logger.info(`Подготовка к прямому деплою DLE в сетях: ${deployParams.supportedChainIds.join(', ')}`);
// Мультисетевой деплой одним вызовом
logger.info('Запуск мульти-чейн деплоя...');
logger.info("🔍 DEBUG: Подготовка к прямому деплою...");
const result = await this.runDeployMultichain(paramsFile, {
rpcUrls: rpcUrls,
chainIds: deployParams.supportedChainIds,
privateKey: dleParams.privateKey?.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`,
salt: create2Salt
});
logger.info('Деплой завершен, результат:', JSON.stringify(result, null, 2));
logger.info("🔍 DEBUG: Запуск мультисетевого деплоя...");
// Сохраняем информацию о созданном DLE для отображения на странице управления
try {
logger.info('Результат деплоя для сохранения:', JSON.stringify(result, null, 2));
// Проверяем структуру результата
if (!result || typeof result !== 'object') {
logger.error('Неверная структура результата деплоя:', result);
throw new Error('Неверная структура результата деплоя');
}
logger.info("🔍 DEBUG: Вызываем runDeployMultichain...");
// Если результат - массив (прямой результат из скрипта), преобразуем его
let deployResult = result;
if (Array.isArray(result)) {
logger.info('Результат - массив, преобразуем в объект');
const addresses = result.map(r => r.address);
const allSame = addresses.every(addr => addr.toLowerCase() === addresses[0].toLowerCase());
deployResult = {
success: true,
data: {
dleAddress: addresses[0],
networks: result.map((r, index) => ({
chainId: r.chainId,
address: r.address,
success: true
})),
allSame
}
};
}
const firstNet = Array.isArray(deployResult?.data?.networks) && deployResult.data.networks.length > 0 ? deployResult.data.networks[0] : null;
const dleData = {
name: deployParams.name,
symbol: deployParams.symbol,
location: deployParams.location,
coordinates: deployParams.coordinates,
jurisdiction: deployParams.jurisdiction,
okvedCodes: deployParams.okvedCodes || [],
kpp: deployParams.kpp,
quorumPercentage: deployParams.quorumPercentage,
initialPartners: deployParams.initialPartners || [],
initialAmounts: deployParams.initialAmounts || [],
governanceSettings: {
quorumPercentage: deployParams.quorumPercentage,
supportedChainIds: deployParams.supportedChainIds,
currentChainId: deployParams.currentChainId
},
dleAddress: (deployResult?.data?.dleAddress) || (firstNet?.address) || null,
version: 'v2',
networks: deployResult?.data?.networks || [],
createdAt: new Date().toISOString()
};
// logger.info('Данные DLE для сохранения:', JSON.stringify(dleData, null, 2)); // Убрано избыточное логирование
if (dleData.dleAddress) {
// Сохраняем данные DLE в файл
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `dle-v2-${timestamp}.json`;
const savedPath = path.join(__dirname, '../contracts-data/dles', fileName);
// Создаем директорию, если её нет
const dlesDir = path.dirname(savedPath);
if (!fs.existsSync(dlesDir)) {
fs.mkdirSync(dlesDir, { recursive: true });
}
fs.writeFileSync(savedPath, JSON.stringify(dleData, null, 2));
// logger.info(`DLE данные сохранены в: ${savedPath}`); // Убрано избыточное логирование
return {
success: true,
data: dleData
};
} else {
throw new Error('DLE адрес не получен после деплоя');
}
} catch (e) {
logger.warn('Не удалось сохранить локальную карточку DLE:', e.message);
}
// Сохраняем ключ Etherscan V2 для последующих авто‑обновлений статуса, если он передан
try {
if (dleParams.etherscanApiKey) {
const { setSecret } = require('./secretStore');
await setSecret('ETHERSCAN_V2_API_KEY', dleParams.etherscanApiKey);
}
} catch (_) {}
// Верификация выполняется в deploy-multichain.js
return result;
} catch (error) {
logger.error('Ошибка при создании DLE v2:', error);
throw error;
} finally {
try {
if (paramsFile || tempParamsFile) {
this.cleanupTempFiles(paramsFile, tempParamsFile);
}
} catch (e) {
logger.warn('Ошибка при очистке временных файлов (finally):', e.message);
}
try {
this.pruneOldTempFiles(24 * 60 * 60 * 1000);
} catch (e) {
logger.warn('Ошибка при автоочистке старых временных файлов:', e.message);
}
}
}
/**
* Валидирует параметры DLE
* @param {Object} params - Параметры для валидации
*/
validateDLEParams(params) {
if (!params.name || params.name.trim() === '') {
throw new Error('Название DLE обязательно');
}
if (!params.symbol || params.symbol.trim() === '') {
throw new Error('Символ токена обязателен');
}
if (!params.location || params.location.trim() === '') {
throw new Error('Местонахождение DLE обязательно');
}
if (!params.initialPartners || !Array.isArray(params.initialPartners)) {
throw new Error('Партнеры должны быть массивом');
}
if (!params.initialAmounts || !Array.isArray(params.initialAmounts)) {
throw new Error('Суммы должны быть массивом');
}
if (params.initialPartners.length !== params.initialAmounts.length) {
throw new Error('Количество партнеров должно соответствовать количеству сумм распределения');
}
if (params.initialPartners.length === 0) {
throw new Error('Должен быть указан хотя бы один партнер');
}
if (params.quorumPercentage > 100 || params.quorumPercentage < 1) {
throw new Error('Процент кворума должен быть от 1% до 100%');
}
// Проверяем адреса партнеров
for (let i = 0; i < params.initialPartners.length; i++) {
if (!ethers.isAddress || !ethers.isAddress(params.initialPartners[i])) {
throw new Error(`Неверный адрес партнера ${i + 1}: ${params.initialPartners[i]}`);
}
}
// Проверяем, что выбраны сети
if (!params.supportedChainIds || !Array.isArray(params.supportedChainIds) || params.supportedChainIds.length === 0) {
throw new Error('Должна быть выбрана хотя бы одна сеть для деплоя');
}
// Дополнительные проверки безопасности
if (params.name.length > 100) {
throw new Error('Название DLE слишком длинное (максимум 100 символов)');
}
if (params.symbol.length > 10) {
throw new Error('Символ токена слишком длинный (максимум 10 символов)');
}
if (params.location.length > 200) {
throw new Error('Местонахождение слишком длинное (максимум 200 символов)');
}
// Проверяем суммы токенов
for (let i = 0; i < params.initialAmounts.length; i++) {
const amount = params.initialAmounts[i];
if (typeof amount !== 'string' && typeof amount !== 'number') {
throw new Error(`Неверный тип суммы для партнера ${i + 1}`);
}
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount) || numAmount <= 0) {
throw new Error(`Неверная сумма для партнера ${i + 1}: ${amount}`);
}
}
// Проверяем приватный ключ
if (!params.privateKey) {
throw new Error('Приватный ключ обязателен для деплоя');
}
const pk = params.privateKey.startsWith('0x') ? params.privateKey : `0x${params.privateKey}`;
if (!/^0x[a-fA-F0-9]{64}$/.test(pk)) {
throw new Error('Неверный формат приватного ключа');
}
// Проверяем, что не деплоим в mainnet без подтверждения
const mainnetChains = [1, 137, 56, 42161]; // Ethereum, Polygon, BSC, Arbitrum
const hasMainnet = params.supportedChainIds.some(id => mainnetChains.includes(id));
if (hasMainnet) {
logger.warn('⚠️ ВНИМАНИЕ: Деплой включает mainnet сети! Убедитесь, что это необходимо.');
}
logger.info('✅ Валидация параметров DLE пройдена успешно');
}
/**
* Сохраняет/обновляет локальную карточку DLE для отображения в UI
* @param {Object} dleData
* @returns {string} Путь к сохраненному файлу
*/
saveDLEData(dleData) {
try {
if (!dleData || !dleData.dleAddress) {
throw new Error('Неверные данные для сохранения карточки DLE: отсутствует dleAddress');
}
const dlesDir = path.join(__dirname, '../contracts-data/dles');
if (!fs.existsSync(dlesDir)) {
fs.mkdirSync(dlesDir, { recursive: true });
}
// Если уже есть файл с таким адресом — обновим его
let targetFile = null;
try {
const files = fs.readdirSync(dlesDir);
for (const file of files) {
if (file.endsWith('.json') && file.includes('dle-v2-')) {
const fp = path.join(dlesDir, file);
try {
const existing = JSON.parse(fs.readFileSync(fp, 'utf8'));
if (existing?.dleAddress && existing.dleAddress.toLowerCase() === dleData.dleAddress.toLowerCase()) {
targetFile = fp;
// Совмещаем данные (не удаляя существующие поля сетей/верификации, если присутствуют)
dleData = { ...existing, ...dleData };
break;
}
} catch (_) {}
}
}
} catch (_) {}
if (!targetFile) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `dle-v2-${ts}.json`;
targetFile = path.join(dlesDir, fileName);
}
fs.writeFileSync(targetFile, JSON.stringify(dleData, null, 2));
logger.info(`Карточка DLE сохранена: ${targetFile}`);
return targetFile;
} catch (e) {
logger.error('Ошибка сохранения карточки DLE:', e);
throw e;
}
}
/**
* Подготавливает параметры для деплоя
* @param {Object} params - Параметры DLE из формы
* @returns {Object} - Подготовленные параметры для скрипта деплоя
*/
prepareDeployParams(params) {
// Создаем копию объекта, чтобы не изменять исходный
const deployParams = { ...params };
// Преобразуем суммы из строк или чисел в BigNumber, если нужно
if (deployParams.initialAmounts && Array.isArray(deployParams.initialAmounts)) {
deployParams.initialAmounts = deployParams.initialAmounts.map(rawAmount => {
// Принимаем как строки, так и числа; конвертируем в base units (18 знаков)
try {
if (typeof rawAmount === 'number' && Number.isFinite(rawAmount)) {
return ethers.parseUnits(rawAmount.toString(), 18).toString();
}
if (typeof rawAmount === 'string') {
const a = rawAmount.trim();
if (a.startsWith('0x')) {
// Уже base units (hex BigNumber) — оставляем как есть
return BigInt(a).toString();
}
// Десятичная строка — конвертируем в base units
return ethers.parseUnits(a, 18).toString();
}
// BigInt или иные типы — приводим к строке без изменения масштаба
return rawAmount.toString();
} catch (e) {
// Фолбэк: безопасно привести к строке
return String(rawAmount);
}
});
}
// Убеждаемся, что okvedCodes - это массив
if (!Array.isArray(deployParams.okvedCodes)) {
deployParams.okvedCodes = [];
}
// Преобразуем kpp в число
if (deployParams.kpp) {
deployParams.kpp = parseInt(deployParams.kpp) || 0;
} else {
deployParams.kpp = 0;
}
// Убеждаемся, что supportedChainIds - это массив
if (!Array.isArray(deployParams.supportedChainIds)) {
deployParams.supportedChainIds = [1]; // По умолчанию Ethereum
}
// Устанавливаем currentChainId как первую выбранную сеть
if (deployParams.supportedChainIds.length > 0) {
deployParams.currentChainId = deployParams.supportedChainIds[0];
} else {
deployParams.currentChainId = 1; // По умолчанию Ethereum
}
// Обрабатываем logoURI
if (deployParams.logoURI) {
// Если logoURI относительный путь, делаем его абсолютным
if (deployParams.logoURI.startsWith('/uploads/')) {
deployParams.logoURI = `http://localhost:8000${deployParams.logoURI}`;
}
// Если это placeholder, оставляем как есть
if (deployParams.logoURI.includes('placeholder.com')) {
// Оставляем как есть
}
}
return deployParams;
}
/**
* Сохраняет параметры во временный файл
* @param {Object} params - Параметры для сохранения
* @returns {string} - Путь к сохраненному файлу
*/
saveParamsToFile(params) {
const tempDir = path.join(__dirname, '../temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const fileName = `dle-v2-params-${Date.now()}.json`;
const filePath = path.join(tempDir, fileName);
fs.writeFileSync(filePath, JSON.stringify(params, null, 2));
return filePath;
}
/**
* Запускает скрипт деплоя DLE v2
* @param {string} paramsFile - Путь к файлу с параметрами
* @returns {Promise<Object>} - Результат деплоя
*/
runDeployScript(paramsFile, extraEnv = {}) {
return new Promise((resolve, reject) => {
const scriptPath = path.join(__dirname, '../scripts/deploy/deploy-multichain.js');
if (!fs.existsSync(scriptPath)) {
reject(new Error('Скрипт деплоя DLE v2 не найден: ' + scriptPath));
return;
}
const envVars = {
...process.env,
RPC_URL: extraEnv.rpcUrl,
PRIVATE_KEY: extraEnv.privateKey
};
const hardhatProcess = spawn('npx', ['hardhat', 'run', scriptPath], {
cwd: path.join(__dirname, '..'),
env: envVars,
stdio: ['inherit', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
hardhatProcess.stdout.on('data', (data) => {
stdout += data.toString();
logger.info(`[DLE v2 Deploy] ${data.toString().trim()}`);
});
hardhatProcess.stderr.on('data', (data) => {
stderr += data.toString();
logger.error(`[DLE v2 Deploy Error] ${data.toString().trim()}`);
});
hardhatProcess.on('close', (code) => {
try {
const result = this.extractDeployResult(stdout);
resolve(result);
} catch (error) {
logger.error('Ошибка при извлечении результатов деплоя DLE v2:', error);
if (code === 0) {
reject(new Error('Не удалось найти информацию о созданном DLE v2'));
} else {
reject(new Error(`Скрипт деплоя DLE v2 завершился с кодом ${code}: ${stderr}`));
}
}
});
hardhatProcess.on('error', (error) => {
logger.error('Ошибка запуска скрипта деплоя DLE v2:', error);
reject(error);
});
});
}
// Мультисетевой деплой
runDeployMultichain(paramsFile, opts = {}) {
return new Promise((resolve, reject) => {
const scriptPath = path.join(__dirname, '../scripts/deploy/deploy-multichain.js');
if (!fs.existsSync(scriptPath)) return reject(new Error('Скрипт мультисетевого деплоя не найден: ' + scriptPath));
const envVars = {
...process.env,
PRIVATE_KEY: opts.privateKey
};
const p = spawn('npx', ['hardhat', 'run', scriptPath], {
cwd: path.join(__dirname, '..'),
env: envVars,
stdio: ['inherit', 'pipe', 'pipe']
});
let stdout = '', stderr = '';
p.stdout.on('data', (d) => {
stdout += d.toString();
logger.info(`[MULTICHAIN_DEPLOY] ${d.toString().trim()}`);
});
p.stderr.on('data', (d) => {
stderr += d.toString();
logger.error(`[MULTICHAIN_DEPLOY_ERR] ${d.toString().trim()}`);
});
p.on('close', (code) => {
try {
// Ищем результат в формате MULTICHAIN_DEPLOY_RESULT
const resultMatch = stdout.match(/MULTICHAIN_DEPLOY_RESULT\s+(\[.*\])/);
if (resultMatch) {
const deployResults = JSON.parse(resultMatch[1]);
// Преобразуем результат в нужный формат
const addresses = deployResults.map(r => r.address);
const allSame = addresses.every(addr => addr.toLowerCase() === addresses[0].toLowerCase());
resolve({
success: true,
data: {
dleAddress: addresses[0],
networks: deployResults.map((r, index) => ({
chainId: r.chainId,
address: r.address,
success: true
})),
allSame
}
});
} else {
// Fallback: ищем адреса DLE в выводе по новому формату
const dleAddressMatches = stdout.match(/\[MULTI_DBG\] chainId=\d+ DLE deployed at=(0x[a-fA-F0-9]{40})/g);
if (!dleAddressMatches || dleAddressMatches.length === 0) {
throw new Error('Не найдены адреса DLE в выводе');
}
const addresses = dleAddressMatches.map(match => match.match(/(0x[a-fA-F0-9]{40})/)[1]);
const addr = addresses[0];
const allSame = addresses.every(x => x.toLowerCase() === addr.toLowerCase());
if (!allSame) {
logger.warn('Адреса отличаются между сетями — продолжаем, сохраню по-сеточно', { addresses });
}
resolve({
success: true,
data: {
dleAddress: addr,
networks: addresses.map((address, index) => ({
chainId: opts.chainIds[index] || index + 1,
address,
success: true
})),
allSame
}
});
}
} catch (e) {
reject(new Error(`Ошибка мультисетевого деплоя: ${e.message}\nSTDOUT:${stdout}\nSTDERR:${stderr}`));
}
});
p.on('error', (e) => reject(e));
});
}
/**
* Извлекает результат деплоя из stdout
* @param {string} stdout - Вывод скрипта
* @returns {Object} - Результат деплоя
*/
extractDeployResult(stdout) {
// Ищем результат в формате MULTICHAIN_DEPLOY_RESULT
const resultMatch = stdout.match(/MULTICHAIN_DEPLOY_RESULT\s+(\[.*?\])/);
if (resultMatch) {
try {
const result = JSON.parse(resultMatch[1]);
return result;
} catch (e) {
logger.error('Ошибка парсинга JSON результата:', e);
}
}
// Fallback: ищем строки с адресами в выводе по новому формату
const dleAddressMatch = stdout.match(/\[MULTI_DBG\] chainId=\d+ DLE deployed at=(0x[a-fA-F0-9]{40})/);
if (dleAddressMatch) {
return {
success: true,
data: {
dleAddress: dleAddressMatch[1],
version: 'v2'
}
};
}
// Если не нашли адрес, выводим весь stdout для отладки
console.log('Полный вывод скрипта:', stdout);
throw new Error('Не удалось извлечь адрес DLE из вывода скрипта');
}
/**
* Очищает временные файлы
* @param {string} paramsFile - Путь к файлу параметров
* @param {string} tempParamsFile - Путь к временному файлу параметров
*/
cleanupTempFiles(paramsFile, tempParamsFile) {
try {
if (fs.existsSync(paramsFile)) {
fs.unlinkSync(paramsFile);
}
if (fs.existsSync(tempParamsFile)) {
fs.unlinkSync(tempParamsFile);
}
} catch (error) {
logger.warn('Не удалось очистить временные файлы:', error);
}
}
/**
* Удаляет временные файлы параметров деплоя старше заданного возраста
* @param {number} maxAgeMs - Макс. возраст файлов в миллисекундах (по умолчанию 24ч)
*/
pruneOldTempFiles(maxAgeMs = 24 * 60 * 60 * 1000) {
const tempDir = path.join(__dirname, '../temp');
try {
if (!fs.existsSync(tempDir)) return;
const now = Date.now();
const files = fs.readdirSync(tempDir).filter(f => f.startsWith('dle-v2-params-') && f.endsWith('.json'));
for (const f of files) {
const fp = path.join(tempDir, f);
try {
const st = fs.statSync(fp);
if (now - st.mtimeMs > maxAgeMs) {
fs.unlinkSync(fp);
logger.info(`Удалён старый временный файл: ${fp}`);
}
} catch (e) {
logger.warn(`Не удалось обработать файл ${fp}: ${e.message}`);
}
}
} catch (e) {
logger.warn('Ошибка pruneOldTempFiles:', e.message);
}
}
/**
* Получает список всех созданных DLE v2
* @returns {Array<Object>} - Список DLE v2
*/
getAllDLEs() {
try {
const dlesDir = path.join(__dirname, '../contracts-data/dles');
if (!fs.existsSync(dlesDir)) {
return [];
}
const files = fs.readdirSync(dlesDir);
const allDles = files
.filter(file => file.endsWith('.json') && file.includes('dle-v2-'))
.map(file => {
try {
const data = JSON.parse(fs.readFileSync(path.join(dlesDir, file), 'utf8'));
return { ...data, _fileName: file };
} catch (error) {
logger.error(`Ошибка при чтении файла ${file}:`, error);
return null;
}
})
.filter(dle => dle !== null);
// Группируем DLE по мультичейн деплоям
const groupedDles = this.groupMultichainDLEs(allDles);
return groupedDles;
} catch (error) {
logger.error('Ошибка при получении списка DLE v2:', error);
return [];
}
}
/**
* Группирует DLE по мультичейн деплоям
* @param {Array<Object>} allDles - Все DLE из файлов
* @returns {Array<Object>} - Сгруппированные DLE
*/
groupMultichainDLEs(allDles) {
const groups = new Map();
for (const dle of allDles) {
// Создаем ключ для группировки на основе общих параметров
const groupKey = this.createGroupKey(dle);
if (!groups.has(groupKey)) {
groups.set(groupKey, {
// Основные данные из первого DLE
name: dle.name,
symbol: dle.symbol,
location: dle.location,
coordinates: dle.coordinates,
jurisdiction: dle.jurisdiction,
oktmo: dle.oktmo,
okvedCodes: dle.okvedCodes,
kpp: dle.kpp,
quorumPercentage: dle.quorumPercentage,
version: dle.version || 'v2',
deployedMultichain: true,
// Мультичейн информация
networks: [],
// Модули (одинаковые во всех сетях)
modules: dle.modules,
// Время создания (самое раннее)
creationTimestamp: dle.creationTimestamp,
creationBlock: dle.creationBlock
});
}
const group = groups.get(groupKey);
// Если у DLE есть массив networks, используем его
if (dle.networks && Array.isArray(dle.networks)) {
for (const network of dle.networks) {
group.networks.push({
chainId: network.chainId,
dleAddress: network.address || network.dleAddress,
factoryAddress: network.factoryAddress,
rpcUrl: network.rpcUrl || this.getRpcUrlForChain(network.chainId)
});
}
} else {
// Старый формат: добавляем информацию о сети из корня DLE
group.networks.push({
chainId: dle.chainId,
dleAddress: dle.dleAddress,
factoryAddress: dle.factoryAddress,
rpcUrl: dle.rpcUrl || this.getRpcUrlForChain(dle.chainId)
});
}
// Обновляем время создания на самое раннее
if (dle.creationTimestamp && (!group.creationTimestamp || dle.creationTimestamp < group.creationTimestamp)) {
group.creationTimestamp = dle.creationTimestamp;
}
}
// Преобразуем группы в массив
return Array.from(groups.values()).map(group => ({
...group,
// Основной адрес DLE (из первой сети)
dleAddress: group.networks[0]?.dleAddress,
// Общее количество сетей
totalNetworks: group.networks.length,
// Поддерживаемые сети
supportedChainIds: group.networks.map(n => n.chainId)
}));
}
/**
* Создает ключ для группировки DLE
* @param {Object} dle - Данные DLE
* @returns {string} - Ключ группировки
*/
createGroupKey(dle) {
// Группируем по основным параметрам DLE
const keyParts = [
dle.name,
dle.symbol,
dle.location,
dle.coordinates,
dle.jurisdiction,
dle.oktmo,
dle.kpp,
dle.quorumPercentage,
// Сортируем okvedCodes для стабильного ключа
Array.isArray(dle.okvedCodes) ? dle.okvedCodes.sort().join(',') : '',
// Сортируем supportedChainIds для стабильного ключа
Array.isArray(dle.supportedChainIds) ? dle.supportedChainIds.sort().join(',') : ''
];
return keyParts.join('|');
}
/**
* Получает RPC URL для сети
* @param {number} chainId - ID сети
* @returns {string|null} - RPC URL
*/
getRpcUrlForChain(chainId) {
try {
// Простая маппинг для основных сетей
const rpcMap = {
1: 'https://eth-mainnet.g.alchemy.com/v2/demo',
11155111: 'https://eth-sepolia.nodereal.io/v1/56dec8028bae4f26b76099a42dae2b52',
17000: 'https://ethereum-holesky.publicnode.com',
421614: 'https://sepolia-rollup.arbitrum.io/rpc',
84532: 'https://sepolia.base.org'
};
return rpcMap[chainId] || null;
} catch (error) {
return null;
}
}
/**
* Проверяет балансы в указанных сетях
* @param {number[]} chainIds - Массив chainId для проверки
* @param {string} privateKey - Приватный ключ
* @returns {Promise<Object>} - Результат проверки балансов
*/
async checkBalances(chainIds, privateKey) {
const { getRpcUrlByChainId } = require('./rpcProviderService');
const { ethers } = require('ethers');
const balances = [];
const insufficient = [];
for (const chainId of chainIds) {
try {
const rpcUrl = await getRpcUrlByChainId(chainId);
if (!rpcUrl) {
balances.push({
chainId,
balanceEth: '0',
ok: false,
error: 'RPC URL не найден'
});
insufficient.push(chainId);
continue;
}
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(privateKey, provider);
const balance = await provider.getBalance(wallet.address);
const balanceEth = ethers.formatEther(balance);
const minBalance = ethers.parseEther("0.001");
const ok = balance >= minBalance;
balances.push({
chainId,
address: wallet.address,
balanceEth,
ok
});
if (!ok) {
insufficient.push(chainId);
}
} catch (error) {
balances.push({
chainId,
balanceEth: '0',
ok: false,
error: error.message
});
insufficient.push(chainId);
}
}
return {
balances,
insufficient,
allSufficient: insufficient.length === 0
};
}
}
module.exports = new DLEV2Service();

View File

@@ -13,10 +13,10 @@
<template> <template>
<div class="contact-table-modal"> <div class="contact-table-modal">
<div class="contact-table-header"> <div class="contact-table-header">
<el-button type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button> <el-button v-if="canManageSettings" type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button>
<el-button type="warning" :disabled="!selectedIds.length" @click="deleteMessagesSelected" style="margin-right: 1em;">Удалить сообщения</el-button> <el-button v-if="canDelete" type="warning" :disabled="!selectedIds.length" @click="deleteMessagesSelected" style="margin-right: 1em;">Удалить сообщения</el-button>
<el-button type="danger" :disabled="!selectedIds.length" @click="deleteSelected" style="margin-right: 1em;">Удалить</el-button> <el-button v-if="canDelete" type="danger" :disabled="!selectedIds.length" @click="deleteSelected" style="margin-right: 1em;">Удалить</el-button>
<el-button type="primary" @click="showImportModal = true" style="margin-right: 1em;">Импорт</el-button> <el-button v-if="canEdit" type="primary" @click="showImportModal = true" style="margin-right: 1em;">Импорт</el-button>
<button class="close-btn" @click="$emit('close')">×</button> <button class="close-btn" @click="$emit('close')">×</button>
</div> </div>
<el-form :inline="true" class="filters-form" label-position="top"> <el-form :inline="true" class="filters-form" label-position="top">
@@ -74,7 +74,7 @@
<table class="contact-table"> <table class="contact-table">
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th> <th v-if="canEdit || canDelete || canManageSettings"><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
<th>Имя</th> <th>Имя</th>
<th>Email</th> <th>Email</th>
<th>Telegram</th> <th>Telegram</th>
@@ -85,7 +85,7 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }"> <tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
<td><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td> <td v-if="canEdit || canDelete || canManageSettings"><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
<td>{{ contact.name || '-' }}</td> <td>{{ contact.name || '-' }}</td>
<td>{{ contact.email || '-' }}</td> <td>{{ contact.email || '-' }}</td>
<td>{{ contact.telegram || '-' }}</td> <td>{{ contact.telegram || '-' }}</td>
@@ -112,6 +112,7 @@ import BroadcastModal from './BroadcastModal.vue';
import tablesService from '../services/tablesService'; import tablesService from '../services/tablesService';
import messagesService from '../services/messagesService'; import messagesService from '../services/messagesService';
import { useTagsWebSocket } from '../composables/useTagsWebSocket'; import { useTagsWebSocket } from '../composables/useTagsWebSocket';
import { usePermissions } from '@/composables/usePermissions';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
newContacts: { type: Array, default: () => [] }, newContacts: { type: Array, default: () => [] },
@@ -123,6 +124,7 @@ const contactsArray = ref([]); // теперь управляем вручную
const newIds = computed(() => props.newContacts.map(c => c.id)); const newIds = computed(() => props.newContacts.map(c => c.id));
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id))); const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
const router = useRouter(); const router = useRouter();
const { canEdit, canDelete, canManageSettings } = usePermissions();
// Фильтры // Фильтры
const filterSearch = ref(''); const filterSearch = ref('');

View File

@@ -12,7 +12,7 @@
<template> <template>
<template v-if="column.type === 'multiselect'"> <template v-if="column.type === 'multiselect'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view"> <div v-if="!editing" @click="canEdit && (editing = true)" class="tags-cell-view">
<span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span> <span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span>
<span v-else class="cell-plus-icon" title="Добавить"> <span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -39,7 +39,7 @@
</div> </div>
</template> </template>
<template v-else-if="column.type === 'relation'"> <template v-else-if="column.type === 'relation'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view"> <div v-if="!editing" @click="canEdit && (editing = true)" class="tags-cell-view">
<span v-if="selectedRelationName">{{ selectedRelationName }}</span> <span v-if="selectedRelationName">{{ selectedRelationName }}</span>
<span v-else class="cell-plus-icon" title="Добавить"> <span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -64,7 +64,7 @@
</div> </div>
</template> </template>
<template v-else-if="column.type === 'multiselect-relation'"> <template v-else-if="column.type === 'multiselect-relation'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view"> <div v-if="!editing" @click="canEdit && (editing = true)" class="tags-cell-view">
<span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.join(', ') }}</span> <span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.join(', ') }}</span>
<span v-else class="cell-plus-icon" title="Добавить"> <span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -97,7 +97,7 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div v-if="!editing" class="cell-view-value" @click="editing = true"> <div v-if="!editing" class="cell-view-value" @click="canEdit && (editing = true)">
<span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span> <span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span>
<span v-else-if="localValue">{{ localValue }}</span> <span v-else-if="localValue">{{ localValue }}</span>
<span v-else class="cell-plus-icon" title="Добавить"> <span v-else class="cell-plus-icon" title="Добавить">
@@ -128,8 +128,11 @@ import tablesService from '../../services/tablesService';
import { useTablesWebSocket } from '../../composables/useTablesWebSocket'; import { useTablesWebSocket } from '../../composables/useTablesWebSocket';
import { useTagsWebSocket } from '../../composables/useTagsWebSocket'; import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
import cacheService from '../../services/cacheService'; import cacheService from '../../services/cacheService';
import { usePermissions } from '@/composables/usePermissions';
const props = defineProps(['rowId', 'column', 'cellValues']); const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const { canEdit } = usePermissions();
const localValue = ref(''); const localValue = ref('');
const editing = ref(false); const editing = ref(false);

View File

@@ -15,9 +15,9 @@
<h2>{{ tableMeta.name }}</h2> <h2>{{ tableMeta.name }}</h2>
<div class="table-desc">{{ tableMeta.description }}</div> <div class="table-desc">{{ tableMeta.description }}</div>
<div class="table-header-actions" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; margin-bottom: 18px;"> <div class="table-header-actions" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; margin-bottom: 18px;">
<el-button type="danger" :disabled="!selectedRows.length" @click="deleteSelectedRows">Удалить выбранные</el-button> <el-button v-if="canEdit" type="danger" :disabled="!selectedRows.length" @click="deleteSelectedRows">Удалить выбранные</el-button>
<span v-if="selectedRows.length">Выбрано: {{ selectedRows.length }}</span> <span v-if="selectedRows.length">Выбрано: {{ selectedRows.length }}</span>
<button v-if="isAdmin" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding"> <button v-if="canEdit" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding">
{{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }} {{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }}
</button> </button>
<el-button @click="resetFilters" type="default" icon="el-icon-refresh">Сбросить фильтры</el-button> <el-button @click="resetFilters" type="default" icon="el-icon-refresh">Сбросить фильтры</el-button>
@@ -68,7 +68,7 @@
</template> </template>
<template v-else> <template v-else>
<span>{{ col.name }}</span> <span>{{ col.name }}</span>
<button class="col-menu" @click.stop="openColMenu(col, $event)">⋮</button> <button v-if="canEdit" class="col-menu" @click.stop="openColMenu(col, $event)">⋮</button>
</template> </template>
</template> </template>
<template #default="{ row }"> <template #default="{ row }">
@@ -90,7 +90,7 @@
:resizable="false" :resizable="false"
> >
<template #header> <template #header>
<button class="add-col-btn" @click.stop="openAddMenu($event)" title="Добавить"> <button v-if="canEdit" class="add-col-btn" @click.stop="openAddMenu($event)" title="Добавить">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="10" fill="#f3f4f6" stroke="#b6c6e6"/> <circle cx="11" cy="11" r="10" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="10" y="5.5" width="2" height="11" rx="1" fill="#4f8cff"/> <rect x="10" y="5.5" width="2" height="11" rx="1" fill="#4f8cff"/>
@@ -105,7 +105,7 @@
</teleport> </teleport>
</template> </template>
<template #default="{ row }"> <template #default="{ row }">
<button class="row-menu" @click.stop="openRowMenu(row, $event)"></button> <button v-if="canEdit" class="row-menu" @click.stop="openRowMenu(row, $event)"></button>
<teleport to="body"> <teleport to="body">
<div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle"> <div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle">
<button class="menu-item" @click="addRowAfter(row)">Добавить строку</button> <button class="menu-item" @click="addRowAfter(row)">Добавить строку</button>
@@ -170,6 +170,7 @@ import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import TableCell from './TableCell.vue'; import TableCell from './TableCell.vue';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import axios from 'axios'; import axios from 'axios';
// Импортируем компоненты Element Plus // Импортируем компоненты Element Plus
import { ElSelect, ElOption, ElButton } from 'element-plus'; import { ElSelect, ElOption, ElButton } from 'element-plus';
@@ -180,6 +181,7 @@ let unsubscribeFromTableUpdate = null;
let unsubscribeFromTagsUpdate = null; let unsubscribeFromTagsUpdate = null;
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canEdit } = usePermissions();
const rebuilding = ref(false); const rebuilding = ref(false);
const rebuildStatus = ref(null); const rebuildStatus = ref(null);

View File

@@ -24,6 +24,7 @@ const email = ref(null);
const processedGuestIds = ref([]); const processedGuestIds = ref([]);
const identities = ref([]); const identities = ref([]);
const tokenBalances = ref([]); const tokenBalances = ref([]);
const userAccessLevel = ref({ level: 'user', tokenCount: 0, hasAccess: false });
// Функция для обновления списка идентификаторов // Функция для обновления списка идентификаторов
const updateIdentities = async () => { const updateIdentities = async () => {
@@ -95,6 +96,20 @@ const checkTokenBalances = async (address) => {
} }
}; };
const checkUserAccessLevel = async (address) => {
try {
const response = await axios.get(`/auth/access-level/${address}`);
if (response.data.success) {
userAccessLevel.value = response.data.data;
return response.data.data;
}
return null;
} catch (error) {
console.error('Error checking user access level:', error);
return null;
}
};
const updateAuth = async ({ const updateAuth = async ({
authenticated, authenticated,
authType: newAuthType, authType: newAuthType,
@@ -140,9 +155,10 @@ const updateAuth = async ({
}) })
); );
// Если аутентификация через кошелек, проверяем баланс токенов // Если аутентификация через кошелек, проверяем баланс токенов и уровень доступа
if (authenticated && newAuthType === 'wallet' && newAddress) { if (authenticated && newAuthType === 'wallet' && newAddress) {
await checkTokenBalances(newAddress); await checkTokenBalances(newAddress);
await checkUserAccessLevel(newAddress);
} }
// Обновляем идентификаторы при любом изменении аутентификации // Обновляем идентификаторы при любом изменении аутентификации
@@ -465,6 +481,7 @@ const authApi = {
identities, identities,
processedGuestIds, processedGuestIds,
tokenBalances, tokenBalances,
userAccessLevel,
updateAuth, updateAuth,
checkAuth, checkAuth,
disconnect, disconnect,
@@ -475,6 +492,7 @@ const authApi = {
linkIdentity, linkIdentity,
deleteIdentity, deleteIdentity,
checkTokenBalances, checkTokenBalances,
checkUserAccessLevel,
}; };
// === PROVIDE/INJECT HELPERS === // === PROVIDE/INJECT HELPERS ===

View File

@@ -0,0 +1,105 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
import { computed } from 'vue';
import { useAuthContext } from './useAuth';
/**
* Composable для работы с правами доступа
* @returns {Object} - Объект с функциями для проверки прав доступа
*/
export function usePermissions() {
const { userAccessLevel, isAdmin } = useAuthContext();
/**
* Проверяет, может ли пользователь только читать данные
*/
const canRead = computed(() => {
return userAccessLevel.value && userAccessLevel.value.hasAccess;
});
/**
* Проверяет, может ли пользователь редактировать данные
*/
const canEdit = computed(() => {
return userAccessLevel.value && userAccessLevel.value.level === 'editor';
});
/**
* Проверяет, может ли пользователь удалять данные
*/
const canDelete = computed(() => {
return userAccessLevel.value && userAccessLevel.value.level === 'editor';
});
/**
* Проверяет, может ли пользователь управлять настройками системы
*/
const canManageSettings = computed(() => {
return userAccessLevel.value && userAccessLevel.value.level === 'editor';
});
/**
* Получает текущий уровень доступа
*/
const currentLevel = computed(() => {
return userAccessLevel.value ? userAccessLevel.value.level : 'user';
});
/**
* Получает количество токенов пользователя
*/
const tokenCount = computed(() => {
return userAccessLevel.value ? userAccessLevel.value.tokenCount : 0;
});
/**
* Получает описание текущего уровня доступа
*/
const getLevelDescription = (level) => {
switch (level) {
case 'readonly':
return 'Только чтение';
case 'editor':
return 'Редактор';
case 'user':
default:
return 'Пользователь';
}
};
/**
* Получает CSS класс для уровня доступа
*/
const getLevelClass = (level) => {
switch (level) {
case 'readonly':
return 'access-readonly';
case 'editor':
return 'access-editor';
case 'user':
default:
return 'access-user';
}
};
return {
canRead,
canEdit,
canDelete,
canManageSettings,
currentLevel,
tokenCount,
getLevelDescription,
getLevelClass
};
}

View File

@@ -212,6 +212,11 @@ const routes = [
name: 'management-dle-management', name: 'management-dle-management',
component: () => import('../views/smartcontracts/DleManagementView.vue') component: () => import('../views/smartcontracts/DleManagementView.vue')
}, },
{
path: '/management/dle-blocks',
name: 'management-dle-blocks',
component: () => import('../views/smartcontracts/DleBlocksManagementView.vue')
},
{ {
path: '/management/proposals', path: '/management/proposals',
name: 'management-proposals', name: 'management-proposals',
@@ -277,6 +282,11 @@ const routes = [
name: 'module-deploy-custom', name: 'module-deploy-custom',
component: () => import('../views/smartcontracts/modules/ModuleDeployFormView.vue') component: () => import('../views/smartcontracts/modules/ModuleDeployFormView.vue')
}, },
{
path: '/management/modules/deploy/inheritance',
name: 'module-deploy-inheritance',
component: () => import('../views/smartcontracts/modules/InheritanceModuleDeploy.vue')
},
// { // {
// path: '/management/multisig', // path: '/management/multisig',
// name: 'management-multisig', // name: 'management-multisig',

View File

@@ -555,32 +555,47 @@ export async function deactivateDLE(dleAddress, userAddress) {
throw new Error('Подключенный кошелек не совпадает с адресом пользователя'); throw new Error('Подключенный кошелек не совпадает с адресом пользователя');
} }
// Сначала проверяем возможность деактивации через API
console.log('Проверяем возможность деактивации DLE через API...');
const checkResponse = await api.post('/blockchain/deactivate-dle', {
dleAddress: dleAddress,
userAddress: userAddress
});
if (!checkResponse.data.success) {
throw new Error(checkResponse.data.error || 'Не удалось проверить возможность деактивации');
}
console.log('Проверка деактивации прошла успешно, выполняем деактивацию...');
// ABI для деактивации DLE // ABI для деактивации DLE
const dleAbi = [ const dleAbi = [
"function deactivate() external", "function deactivate() external",
"function balanceOf(address) external view returns (uint256)", "function balanceOf(address) external view returns (uint256)",
"function totalSupply() external view returns (uint256)", "function totalSupply() external view returns (uint256)",
"function createDeactivationProposal(string memory _description, uint256 _duration, uint256 _chainId) external returns (uint256)", "function isActive() external view returns (bool)"
"function voteDeactivation(uint256 _proposalId, bool _support) external",
"function checkDeactivationProposalResult(uint256 _proposalId) public view returns (bool passed, bool quorumReached)",
"function executeDeactivationProposal(uint256 _proposalId) external"
]; ];
const dle = new ethers.Contract(dleAddress, dleAbi, signer); const dle = new ethers.Contract(dleAddress, dleAbi, signer);
// Проверяем, что пользователь имеет токены // Дополнительные проверки перед деактивацией
const balance = await dle.balanceOf(userAddress); const balance = await dle.balanceOf(userAddress);
if (balance <= 0) { if (balance <= 0) {
throw new Error('Для деактивации DLE необходимо иметь токены'); throw new Error('Для деактивации DLE необходимо иметь токены');
} }
// Проверяем, что DLE не пустой (есть токены)
const totalSupply = await dle.totalSupply(); const totalSupply = await dle.totalSupply();
if (totalSupply <= 0) { if (totalSupply <= 0) {
throw new Error('DLE не имеет токенов'); throw new Error('DLE не имеет токенов');
} }
// Выполняем деактивацию (функция проверит наличие валидного предложения с кворумом) const isActive = await dle.isActive();
if (!isActive) {
throw new Error('DLE уже деактивирован');
}
// Выполняем деактивацию
console.log('Выполняем деактивацию DLE...');
const tx = await dle.deactivate(); const tx = await dle.deactivate();
const receipt = await tx.wait(); const receipt = await tx.wait();
@@ -595,7 +610,25 @@ export async function deactivateDLE(dleAddress, userAddress) {
} catch (error) { } catch (error) {
console.error('Ошибка деактивации DLE:', error); console.error('Ошибка деактивации DLE:', error);
throw error;
// Улучшенная обработка ошибок
let errorMessage = 'Ошибка при деактивации DLE';
if (error.message.includes('execution reverted')) {
errorMessage = '❌ Деактивация невозможна: не выполнены условия смарт-контракта. Возможно, требуется голосование участников или DLE уже деактивирован.';
} else if (error.message.includes('владелец')) {
errorMessage = '❌ Только владелец DLE может его деактивировать';
} else if (error.message.includes('кошелек')) {
errorMessage = '❌ Необходимо подключить кошелек';
} else if (error.message.includes('деактивирован')) {
errorMessage = '❌ DLE уже деактивирован';
} else if (error.message.includes('токены')) {
errorMessage = '❌ Для деактивации DLE необходимо иметь токены';
} else {
errorMessage = `❌ Ошибка: ${error.message}`;
}
throw new Error(errorMessage);
} }
} }

View File

@@ -16,7 +16,7 @@
<span>Контакты</span> <span>Контакты</span>
<span v-if="newContacts.length" class="badge">+{{ newContacts.length }}</span> <span v-if="newContacts.length" class="badge">+{{ newContacts.length }}</span>
</div> </div>
<ContactTable v-if="isAdmin" :contacts="contacts" :new-contacts="newContacts" :new-messages="newMessages" @markNewAsRead="markContactsAsRead" <ContactTable v-if="canRead" :contacts="contacts" :new-contacts="newContacts" :new-messages="newMessages" @markNewAsRead="markContactsAsRead"
:markMessagesAsReadForUser="markMessagesAsReadForUser" :markContactAsRead="markContactAsRead" @close="goBack" /> :markMessagesAsReadForUser="markMessagesAsReadForUser" :markContactAsRead="markContactAsRead" @close="goBack" />
<!-- Таблица-заглушка для обычных пользователей --> <!-- Таблица-заглушка для обычных пользователей -->
@@ -92,6 +92,7 @@ import BaseLayout from '../components/BaseLayout.vue';
import ContactTable from '../components/ContactTable.vue'; import ContactTable from '../components/ContactTable.vue';
import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket'; import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
const { const {
contacts, newContacts, newMessages, contacts, newContacts, newMessages,
@@ -99,6 +100,7 @@ const {
} = useContactsAndMessagesWebSocket(); } = useContactsAndMessagesWebSocket();
const router = useRouter(); const router = useRouter();
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canRead } = usePermissions();
function goBack() { function goBack() {
if (window.history.length > 1) { if (window.history.length > 1) {

View File

@@ -18,7 +18,7 @@
:is-loading-tokens="isLoadingTokens" :is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')" @auth-action-completed="$emit('auth-action-completed')"
> >
<template v-if="auth.isAdmin.value"> <template v-if="auth.userAccessLevel.value && auth.userAccessLevel.value.hasAccess">
<ChatInterface <ChatInterface
:messages="messages" :messages="messages"
:is-loading="isLoading || isConnectingWallet" :is-loading="isLoading || isConnectingWallet"

View File

@@ -54,8 +54,7 @@
v-for="dle in deployedDles" v-for="dle in deployedDles"
:key="dle.dleAddress" :key="dle.dleAddress"
class="dle-card" class="dle-card"
:class="{ 'selected': selectedDle && selectedDle.dleAddress === dle.dleAddress }" @click="openDleManagement(dle.dleAddress)"
@click="selectDle(dle)"
> >
<div class="dle-header"> <div class="dle-header">
<div class="dle-title-section"> <div class="dle-title-section">
@@ -148,6 +147,7 @@
</ul> </ul>
<button class="details-btn btn-sm" @click.stop="refreshVerification(dle.dleAddress)">Обновить статус</button> <button class="details-btn btn-sm" @click.stop="refreshVerification(dle.dleAddress)">Обновить статус</button>
</div> </div>
</div> </div>
@@ -157,64 +157,6 @@
<!-- Блоки управления выбранным DLE -->
<div v-if="selectedDle" class="management-blocks">
<!-- Первый ряд -->
<div class="blocks-row">
<div class="management-block">
<h3>Предложения</h3>
<p>Создание, подписание, выполнение</p>
<button class="details-btn" @click="openProposalsWithDle">Подробнее</button>
</div>
<div class="management-block">
<h3>Токены DLE</h3>
<p>Балансы, трансферы, распределение</p>
<button class="details-btn" @click="openTokensWithDle">Подробнее</button>
</div>
<div class="management-block">
<h3>Кворум</h3>
<p>Настройки голосования</p>
<button class="details-btn" @click="openQuorumWithDle">Подробнее</button>
</div>
</div>
<!-- Второй ряд -->
<div class="blocks-row">
<div class="management-block">
<h3>Модули DLE</h3>
<p>Установка, настройка, управление</p>
<button class="details-btn" @click="openModulesWithDle">Подробнее</button>
</div>
<div class="management-block">
<h3>Аналитика</h3>
<p>Графики, статистика, отчеты</p>
<button class="details-btn" @click="openAnalyticsWithDle">Подробнее</button>
</div>
</div>
<!-- Третий ряд -->
<div class="blocks-row">
<div class="management-block">
<h3>История</h3>
<p>Лог операций, события, транзакции</p>
<button class="details-btn" @click="openHistoryWithDle">Подробнее</button>
</div>
<div class="management-block">
<h3>Настройки</h3>
<p>Параметры DLE, конфигурация</p>
<button class="details-btn" @click="openSettingsWithDle">Подробнее</button>
</div>
</div>
</div>
</div> </div>
@@ -245,7 +187,6 @@ const router = useRouter();
// Состояние для DLE // Состояние для DLE
const deployedDles = ref([]); const deployedDles = ref([]);
const isLoadingDles = ref(false); const isLoadingDles = ref(false);
const selectedDle = ref(null);
const verificationStatuses = ref({}); // { [address]: { address, chains: { [chainId]: { guid, status } } } } const verificationStatuses = ref({}); // { [address]: { address, chains: { [chainId]: { guid, status } } } }
let verifyPollTimer = null; let verifyPollTimer = null;
@@ -431,14 +372,10 @@ function openDleOnEtherscan(address) {
} }
function openDleManagement(dleAddress) { function openDleManagement(dleAddress) {
// Переход к детальному управлению DLE (если нужно) // Переход к блокам управления DLE
router.push(`/management/dle-management?address=${dleAddress}`); router.push(`/management/dle-blocks?address=${dleAddress}`);
} }
function selectDle(dle) {
selectedDle.value = dle;
console.log('Выбран DLE:', dle);
}
async function refreshVerification(address) { async function refreshVerification(address) {
try { try {
@@ -474,56 +411,6 @@ async function pollVerifications() {
// router.push('/management/multisig'); // router.push('/management/multisig');
// } // }
// Функции с передачей адреса DLE
function openProposalsWithDle() {
if (selectedDle.value) {
router.push(`/management/proposals?address=${selectedDle.value.dleAddress}`);
}
}
function openTokensWithDle() {
if (selectedDle.value) {
router.push(`/management/tokens?address=${selectedDle.value.dleAddress}`);
}
}
function openQuorumWithDle() {
if (selectedDle.value) {
router.push(`/management/quorum?address=${selectedDle.value.dleAddress}`);
}
}
function openModulesWithDle() {
if (selectedDle.value) {
router.push(`/management/modules?address=${selectedDle.value.dleAddress}`);
}
}
function openAnalyticsWithDle() {
if (selectedDle.value) {
router.push(`/management/analytics?address=${selectedDle.value.dleAddress}`);
}
}
function openHistoryWithDle() {
if (selectedDle.value) {
router.push(`/management/history?address=${selectedDle.value.dleAddress}`);
}
}
function openSettingsWithDle() {
if (selectedDle.value) {
router.push(`/management/settings?address=${selectedDle.value.dleAddress}`);
}
}
// function openMultisigWithDle() {
// if (selectedDle.value) {
// router.push(`/management/multisig?address=${selectedDle.value.dleAddress}`);
// }
// }
@@ -586,37 +473,6 @@ onBeforeUnmount(() => {
color: #333; color: #333;
} }
/* Блоки управления */
.management-blocks {
display: flex;
flex-direction: column;
gap: 2rem;
margin-top: 2rem;
}
.blocks-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.management-block {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 2rem;
min-width: 260px;
display: flex;
flex-direction: column;
align-items: flex-start;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
.management-block:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
/* Секция деплоированных DLE */ /* Секция деплоированных DLE */
.deployed-dles-section { .deployed-dles-section {
@@ -905,6 +761,7 @@ onBeforeUnmount(() => {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Стили для отображения логотипа */ /* Стили для отображения логотипа */
.dle-title-section { .dle-title-section {
display: flex; display: flex;
@@ -998,17 +855,6 @@ onBeforeUnmount(() => {
/* Адаптивность */ /* Адаптивность */
@media (max-width: 768px) { @media (max-width: 768px) {
.blocks-row {
grid-template-columns: 1fr;
}
.management-block {
padding: 1.5rem;
}
.management-block h3 {
font-size: 1.3rem;
}
.partner-info { .partner-info {
flex-direction: column; flex-direction: column;

View File

@@ -22,10 +22,10 @@
<p><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</p> <p><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</p>
<p><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</p> <p><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</p>
<div class="confirm-actions"> <div class="confirm-actions">
<button v-if="isAdmin" class="delete-btn" @click="deleteContact" :disabled="isDeleting">Удалить</button> <button v-if="canDelete" class="delete-btn" @click="deleteContact" :disabled="isDeleting">Удалить</button>
<button class="cancel-btn" @click="cancelDelete" :disabled="isDeleting">Отменить</button> <button class="cancel-btn" @click="cancelDelete" :disabled="isDeleting">Отменить</button>
</div> </div>
<div v-if="!isAdmin" class="empty-table-placeholder">Нет прав для удаления контакта</div> <div v-if="!canDelete" class="empty-table-placeholder">Нет прав для удаления контакта</div>
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
</div> </div>
</div> </div>
@@ -36,6 +36,7 @@ import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import contactsService from '../../services/contactsService.js'; import contactsService from '../../services/contactsService.js';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -44,6 +45,7 @@ const isLoading = ref(true);
const isDeleting = ref(false); const isDeleting = ref(false);
const error = ref(''); const error = ref('');
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canDelete } = usePermissions();
function formatDate(date) { function formatDate(date) {
if (!date) return '-'; if (!date) return '-';

View File

@@ -12,7 +12,7 @@
<template> <template>
<BaseLayout> <BaseLayout>
<div v-if="!isAdmin" class="empty-table-placeholder">Нет доступа</div> <div v-if="!canRead" class="empty-table-placeholder">Нет доступа</div>
<div v-else class="contact-details-page"> <div v-else class="contact-details-page">
<div v-if="isLoading">Загрузка...</div> <div v-if="isLoading">Загрузка...</div>
<div v-else-if="!contact">Контакт не найден</div> <div v-else-if="!contact">Контакт не найден</div>
@@ -24,7 +24,7 @@
<div class="contact-info-block"> <div class="contact-info-block">
<div> <div>
<strong>Имя:</strong> <strong>Имя:</strong>
<template v-if="isAdmin"> <template v-if="canEdit">
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" /> <input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
<span v-if="isSavingName" class="saving">Сохранение...</span> <span v-if="isSavingName" class="saving">Сохранение...</span>
</template> </template>
@@ -41,9 +41,10 @@
<div class="selected-langs"> <div class="selected-langs">
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag"> <span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
{{ getLanguageLabel(lang) }} {{ getLanguageLabel(lang) }}
<span class="remove-tag" @click="removeLanguage(lang)">×</span> <span v-if="canEdit" class="remove-tag" @click="removeLanguage(lang)">×</span>
</span> </span>
<input <input
v-if="canEdit"
v-model="langInput" v-model="langInput"
@focus="showLangDropdown = true" @focus="showLangDropdown = true"
@input="showLangDropdown = true" @input="showLangDropdown = true"
@@ -52,7 +53,7 @@
placeholder="Добавить язык..." placeholder="Добавить язык..."
/> />
</div> </div>
<ul v-if="showLangDropdown" class="lang-dropdown"> <ul v-if="showLangDropdown && canEdit" class="lang-dropdown">
<li <li
v-for="lang in filteredLanguages" v-for="lang in filteredLanguages"
:key="lang.value" :key="lang.value"
@@ -71,15 +72,15 @@
<strong>Теги пользователя:</strong> <strong>Теги пользователя:</strong>
<span v-for="tag in userTags" :key="tag.id" class="user-tag"> <span v-for="tag in userTags" :key="tag.id" class="user-tag">
{{ tag.name }} {{ tag.name }}
<span class="remove-tag" @click="removeUserTag(tag.id)">×</span> <span v-if="canEdit" class="remove-tag" @click="removeUserTag(tag.id)">×</span>
</span> </span>
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button> <button v-if="canEdit" class="add-tag-btn" @click="openTagModal">Добавить тег</button>
</div> </div>
<div class="block-user-section"> <div class="block-user-section">
<strong>Статус блокировки:</strong> <strong>Статус блокировки:</strong>
<span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span> <span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span>
<span v-else class="unblocked-status">Не заблокирован</span> <span v-else class="unblocked-status">Не заблокирован</span>
<template v-if="isAdmin"> <template v-if="canEdit">
<el-button <el-button
v-if="!contact.is_blocked" v-if="!contact.is_blocked"
type="danger" type="danger"
@@ -108,14 +109,14 @@
:isLoading="isLoadingMessages" :isLoading="isLoadingMessages"
:attachments="chatAttachments" :attachments="chatAttachments"
:newMessage="chatNewMessage" :newMessage="chatNewMessage"
:isAdmin="isAdmin" :isAdmin="canEdit"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@update:newMessage="val => chatNewMessage = val" @update:newMessage="val => chatNewMessage = val"
@update:attachments="val => chatAttachments = val" @update:attachments="val => chatAttachments = val"
@ai-reply="handleAiReply" @ai-reply="handleAiReply"
/> />
</div> </div>
<el-dialog v-model="showTagModal" title="Добавить тег пользователю"> <el-dialog v-if="canEdit" v-model="showTagModal" title="Добавить тег пользователю">
<div v-if="allTags.length"> <div v-if="allTags.length">
<el-select <el-select
v-model="selectedTags" v-model="selectedTags"
@@ -158,6 +159,7 @@ import ChatInterface from '../../components/ChatInterface.vue';
import contactsService from '../../services/contactsService.js'; import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js'; import messagesService from '../../services/messagesService.js';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import { useTagsWebSocket } from '../../composables/useTagsWebSocket'; import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
@@ -182,6 +184,7 @@ const messages = ref([]);
const chatAttachments = ref([]); const chatAttachments = ref([]);
const chatNewMessage = ref(''); const chatNewMessage = ref('');
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canRead, canEdit, canDelete } = usePermissions();
const isAiLoading = ref(false); const isAiLoading = ref(false);
const conversationId = ref(null); const conversationId = ref(null);
@@ -250,6 +253,7 @@ async function loadAllTags() {
} }
function openTagModal() { function openTagModal() {
if (!canEdit.value) return;
showTagModal.value = true; showTagModal.value = true;
loadAllTags(); loadAllTags();
} }
@@ -289,6 +293,7 @@ function getLanguageLabel(val) {
return found ? found.label : val; return found ? found.label : val;
} }
function addLanguage(lang) { function addLanguage(lang) {
if (!canEdit.value) return;
if (!selectedLanguages.value.includes(lang)) { if (!selectedLanguages.value.includes(lang)) {
selectedLanguages.value.push(lang); selectedLanguages.value.push(lang);
saveLanguages(); saveLanguages();
@@ -297,14 +302,17 @@ function addLanguage(lang) {
showLangDropdown.value = false; showLangDropdown.value = false;
} }
function addLanguageFromInput() { function addLanguageFromInput() {
if (!canEdit.value) return;
const found = filteredLanguages.value[0]; const found = filteredLanguages.value[0];
if (found) addLanguage(found.value); if (found) addLanguage(found.value);
} }
function removeLanguage(lang) { function removeLanguage(lang) {
if (!canEdit.value) return;
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang); selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
saveLanguages(); saveLanguages();
} }
function saveLanguages() { function saveLanguages() {
if (!canEdit.value) return;
isSavingLangs.value = true; isSavingLangs.value = true;
contactsService.updateContact(contact.value.id, { language: selectedLanguages.value }) contactsService.updateContact(contact.value.id, { language: selectedLanguages.value })
.then(() => reloadContact()) .then(() => reloadContact())
@@ -529,6 +537,7 @@ async function unblockUser() {
// --- Теги --- // --- Теги ---
async function createTag() { async function createTag() {
if (!canEdit.value) return;
if (!newTagName.value) return; if (!newTagName.value) return;
const tableId = await ensureTagsTable(); const tableId = await ensureTagsTable();
const table = await tablesService.getTable(tableId); const table = await tablesService.getTable(tableId);
@@ -588,6 +597,7 @@ async function loadUserTags() {
// После добавления/удаления тегов всегда обновляем userTags // После добавления/удаления тегов всегда обновляем userTags
async function addTagsToUser() { async function addTagsToUser() {
if (!canEdit.value) return;
if (!contact.value || !contact.value.id) return; if (!contact.value || !contact.value.id) return;
if (!selectedTags.value || selectedTags.value.length === 0) return; if (!selectedTags.value || selectedTags.value.length === 0) return;
try { try {
@@ -601,6 +611,7 @@ async function addTagsToUser() {
} }
async function removeUserTag(tagId) { async function removeUserTag(tagId) {
if (!canEdit.value) return;
if (!contact.value || !contact.value.id) return; if (!contact.value || !contact.value.id) return;
try { try {
await contactsService.removeTagFromContact(contact.value.id, tagId); await contactsService.removeTagFromContact(contact.value.id, tagId);

View File

@@ -101,6 +101,80 @@
{{ em.from_email }} {{ em.from_email }}
</option> </option>
</select> </select>
<!-- Настройки RAG поиска -->
<div class="rag-search-settings">
<h3>Настройки RAG поиска</h3>
<!-- Метод поиска -->
<label>Метод поиска</label>
<select v-model="ragSettings.searchMethod">
<option value="semantic">Только семантический поиск</option>
<option value="keyword">Только поиск по ключевым словам</option>
<option value="hybrid">Гибридный поиск</option>
</select>
<!-- Количество результатов -->
<label>Максимальное количество результатов поиска</label>
<input type="number" v-model="ragSettings.maxResults" min="1" max="20" />
<!-- Порог релевантности -->
<label>Порог релевантности ({{ ragSettings.relevanceThreshold }})</label>
<input type="range" v-model="ragSettings.relevanceThreshold"
min="0.01" max="1.0" step="0.01" />
<!-- Настройки извлечения ключевых слов -->
<div class="keyword-settings">
<h4>Извлечение ключевых слов</h4>
<label class="checkbox-label">
<input type="checkbox" v-model="ragSettings.keywordExtraction.enabled" />
Включить извлечение ключевых слов
</label>
<label>Минимальная длина слова</label>
<input type="number" v-model="ragSettings.keywordExtraction.minWordLength"
min="2" max="10" />
<label>Максимальное количество ключевых слов</label>
<input type="number" v-model="ragSettings.keywordExtraction.maxKeywords"
min="5" max="20" />
<label class="checkbox-label">
<input type="checkbox" v-model="ragSettings.keywordExtraction.removeStopWords" />
Удалять стоп-слова
</label>
</div>
<!-- Веса для гибридного поиска -->
<div v-if="ragSettings.searchMethod === 'hybrid'" class="search-weights">
<h4>Веса поиска</h4>
<label>Семантический поиск: {{ ragSettings.searchWeights.semantic }}%</label>
<input type="range" v-model="ragSettings.searchWeights.semantic"
min="0" max="100" />
<label>Поиск по ключевым словам: {{ ragSettings.searchWeights.keyword }}%</label>
<input type="range" v-model="ragSettings.searchWeights.keyword"
min="0" max="100" />
</div>
<!-- Дополнительные настройки -->
<div class="advanced-settings">
<h4>Дополнительные настройки</h4>
<label class="checkbox-label">
<input type="checkbox" v-model="ragSettings.advanced.enableFuzzySearch" />
Нечеткий поиск
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="ragSettings.advanced.enableStemming" />
Стемминг слов
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="ragSettings.advanced.enableSynonyms" />
Поиск синонимов
</label>
</div>
</div>
<div class="actions"> <div class="actions">
<button type="submit">Сохранить</button> <button type="submit">Сохранить</button>
<button type="button" @click="goBack">Отмена</button> <button type="button" @click="goBack">Отмена</button>
@@ -143,6 +217,29 @@ const placeholders = ref([]);
const editingPlaceholder = ref(null); const editingPlaceholder = ref(null);
const editingPlaceholderValue = ref(''); const editingPlaceholderValue = ref('');
// Настройки RAG поиска
const ragSettings = ref({
searchMethod: 'hybrid',
maxResults: 5,
relevanceThreshold: 0.1,
keywordExtraction: {
enabled: true,
minWordLength: 3,
maxKeywords: 10,
removeStopWords: true,
language: 'ru'
},
searchWeights: {
semantic: 70,
keyword: 30
},
advanced: {
enableFuzzySearch: true,
enableStemming: true,
enableSynonyms: false
}
});
async function loadUserTables() { async function loadUserTables() {
const { data } = await axios.get('/tables'); const { data } = await axios.get('/tables');
userTables.value = Array.isArray(data) ? data : []; userTables.value = Array.isArray(data) ? data : [];
@@ -165,7 +262,14 @@ async function loadSettings() {
} }
settings.value = settingsData; settings.value = settingsData;
// Загружаем настройки RAG, если они есть
if (data.settings.ragSettings) {
ragSettings.value = { ...ragSettings.value, ...data.settings.ragSettings };
}
console.log('[AiAssistantSettings] Loaded settings:', settings.value); console.log('[AiAssistantSettings] Loaded settings:', settings.value);
console.log('[AiAssistantSettings] Loaded RAG settings:', ragSettings.value);
} }
} }
async function loadTelegramBots() { async function loadTelegramBots() {
@@ -225,7 +329,11 @@ async function saveSettings() {
settingsToSave.selected_rag_tables = [settingsToSave.selected_rag_tables]; settingsToSave.selected_rag_tables = [settingsToSave.selected_rag_tables];
} }
// Добавляем настройки RAG
settingsToSave.ragSettings = ragSettings.value;
console.log('[AiAssistantSettings] Saving settings:', settingsToSave); console.log('[AiAssistantSettings] Saving settings:', settingsToSave);
console.log('[AiAssistantSettings] Saving RAG settings:', ragSettings.value);
await axios.put('/settings/ai-assistant', settingsToSave); await axios.put('/settings/ai-assistant', settingsToSave);
goBack(); goBack();
} }
@@ -411,4 +519,63 @@ button[type="button"] {
font-size: 1em; font-size: 1em;
margin: 0.7em 0; margin: 0.7em 0;
} }
/* Стили для настроек RAG поиска */
.rag-search-settings {
margin: 2rem 0;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.rag-search-settings h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
font-size: 1.2rem;
}
.keyword-settings, .search-weights, .advanced-settings {
margin: 1rem 0;
padding: 1rem;
background: #fff;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.keyword-settings h4, .search-weights h4, .advanced-settings h4 {
margin-top: 0;
margin-bottom: 1rem;
color: #555;
font-size: 1rem;
}
.search-weights input[type="range"] {
width: 100%;
margin: 0.5rem 0;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0;
font-weight: normal;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 0;
}
.rag-search-settings input[type="range"] {
width: 100%;
margin: 0.5rem 0;
}
.rag-search-settings input[type="number"] {
width: 100px;
margin-right: 1rem;
}
</style> </style>

View File

@@ -70,6 +70,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import AIProviderSettings from './AIProviderSettings.vue'; import AIProviderSettings from './AIProviderSettings.vue';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import NoAccessModal from '@/components/NoAccessModal.vue'; import NoAccessModal from '@/components/NoAccessModal.vue';
const showProvider = ref(null); const showProvider = ref(null);
@@ -80,6 +81,7 @@ const showAiAssistantSettings = ref(false);
const showNoAccessModal = ref(false); const showNoAccessModal = ref(false);
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canManageSettings } = usePermissions();
const providerLabels = { const providerLabels = {
openai: { openai: {
@@ -117,7 +119,7 @@ const providerLabels = {
}; };
function goTo(path) { function goTo(path) {
if (!isAdmin.value) { if (!canManageSettings.value) {
showNoAccessModal.value = true; showNoAccessModal.value = true;
return; return;
} }

View File

@@ -13,17 +13,31 @@
<template> <template>
<div class="auth-tokens-settings"> <div class="auth-tokens-settings">
<h4>Токены аутентификации</h4> <h4>Токены аутентификации</h4>
<!-- Отображение текущего уровня доступа -->
<div v-if="userAccessLevel && userAccessLevel.hasAccess" class="access-level-info">
<div class="access-level-badge" :class="getLevelClass(userAccessLevel.level)">
<i class="fas fa-shield-alt"></i>
<span>{{ getLevelDescription(userAccessLevel.level) }}</span>
<span class="token-count">({{ userAccessLevel.tokenCount }} токен{{ userAccessLevel.tokenCount === 1 ? '' : userAccessLevel.tokenCount < 5 ? 'а' : 'ов' }})</span>
</div>
<div class="access-level-description">
{{ getAccessLevelDescription(userAccessLevel.level) }}
</div>
</div>
<div v-if="authTokens.length > 0" class="tokens-list"> <div v-if="authTokens.length > 0" class="tokens-list">
<div v-for="(token, index) in authTokens" :key="token.address + token.network" class="token-entry"> <div v-for="(token, index) in authTokens" :key="token.address + token.network" class="token-entry">
<span><strong>Название:</strong> {{ token.name }}</span> <span><strong>Название:</strong> {{ token.name }}</span>
<span><strong>Адрес:</strong> {{ token.address }}</span> <span><strong>Адрес:</strong> {{ token.address }}</span>
<span><strong>Сеть:</strong> {{ getNetworkLabel(token.network) }}</span> <span><strong>Сеть:</strong> {{ getNetworkLabel(token.network) }}</span>
<span><strong>Мин. баланс:</strong> {{ token.minBalance }}</span> <span><strong>Мин. баланс:</strong> {{ token.minBalance }}</span>
<span><strong>Read-Only:</strong> {{ token.readonlyThreshold || 1 }} токен{{ token.readonlyThreshold === 1 ? '' : 'а' }}</span>
<span><strong>Editor:</strong> {{ token.editorThreshold || 2 }} токен{{ token.editorThreshold === 1 ? '' : token.editorThreshold < 5 ? 'а' : 'ов' }}</span>
<button <button
class="btn btn-sm" class="btn btn-sm"
:class="isAdmin ? 'btn-danger' : 'btn-secondary'" :class="canEdit ? 'btn-danger' : 'btn-secondary'"
@click="isAdmin ? removeToken(index) : null" @click="canEdit ? removeToken(index) : null"
:disabled="!isAdmin" :disabled="!canEdit"
> >
Удалить Удалить
</button> </button>
@@ -39,7 +53,7 @@
v-model="newToken.name" v-model="newToken.name"
class="form-control" class="form-control"
placeholder="test2" placeholder="test2"
:disabled="!isAdmin" :disabled="!canEdit"
> >
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -49,12 +63,12 @@
v-model="newToken.address" v-model="newToken.address"
class="form-control" class="form-control"
placeholder="0x..." placeholder="0x..."
:disabled="!isAdmin" :disabled="!canEdit"
> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Сеть:</label> <label>Сеть:</label>
<select v-model="newToken.network" class="form-control" :disabled="!isAdmin"> <select v-model="newToken.network" class="form-control" :disabled="!canEdit">
<option value="">-- Выберите сеть --</option> <option value="">-- Выберите сеть --</option>
<optgroup v-for="(group, groupIndex) in networkGroups" :key="groupIndex" :label="group.label"> <optgroup v-for="(group, groupIndex) in networkGroups" :key="groupIndex" :label="group.label">
<option v-for="option in group.options" :key="option.value" :value="option.value"> <option v-for="option in group.options" :key="option.value" :value="option.value">
@@ -70,14 +84,43 @@
v-model.number="newToken.minBalance" v-model.number="newToken.minBalance"
class="form-control" class="form-control"
placeholder="0" placeholder="0"
:disabled="!isAdmin" :disabled="!canEdit"
> >
</div> </div>
<!-- Настройки прав доступа -->
<div class="access-settings">
<h6>Настройки прав доступа</h6>
<div class="form-group">
<label>Минимум токенов для Read-Only доступа:</label>
<input
type="number"
v-model="newToken.readonlyThreshold"
class="form-control"
placeholder="1"
min="1"
:disabled="!canEdit"
>
<small class="form-text">Количество токенов для получения прав только на чтение</small>
</div>
<div class="form-group">
<label>Минимум токенов для Editor доступа:</label>
<input
type="number"
v-model="newToken.editorThreshold"
class="form-control"
placeholder="2"
min="2"
:disabled="!canEdit"
>
<small class="form-text">Количество токенов для получения прав на редактирование и удаление</small>
</div>
</div>
<button <button
class="btn" class="btn"
:class="isAdmin ? 'btn-secondary' : 'btn-secondary'" :class="canEdit ? 'btn-primary' : 'btn-secondary'"
@click="isAdmin ? addToken() : null" @click="canEdit ? addToken() : null"
:disabled="!isAdmin" :disabled="!canEdit"
> >
Добавить токен Добавить токен
</button> </button>
@@ -86,36 +129,58 @@
</template> </template>
<script setup> <script setup>
import { reactive } from 'vue'; import { reactive, computed } from 'vue';
import useBlockchainNetworks from '@/composables/useBlockchainNetworks'; import useBlockchainNetworks from '@/composables/useBlockchainNetworks';
import api from '@/api/axios'; import api from '@/api/axios';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import eventBus from '@/utils/eventBus'; import eventBus from '@/utils/eventBus';
const props = defineProps({ const props = defineProps({
authTokens: { type: Array, required: true } authTokens: { type: Array, required: true }
}); });
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const newToken = reactive({ name: '', address: '', network: '', minBalance: 0 }); const newToken = reactive({
name: '',
address: '',
network: '',
minBalance: 0,
readonlyThreshold: 1,
editorThreshold: 2
});
const { networkGroups, networks } = useBlockchainNetworks(); const { networkGroups, networks } = useBlockchainNetworks();
const { isAdmin, checkTokenBalances, address, checkAuth } = useAuthContext(); const { isAdmin, checkTokenBalances, address, checkAuth, userAccessLevel, checkUserAccessLevel } = useAuthContext();
const { canEdit, getLevelClass, getLevelDescription } = usePermissions();
async function addToken() { async function addToken() {
if (!newToken.name || !newToken.address || !newToken.network) { if (!newToken.name || !newToken.address || !newToken.network) {
alert('Все поля обязательны'); alert('Все поля обязательны');
return; return;
} }
const tokenData = {
name: newToken.name,
address: newToken.address,
network: newToken.network,
minBalance: Number(newToken.minBalance) || 0,
readonlyThreshold: newToken.readonlyThreshold !== null && newToken.readonlyThreshold !== undefined && newToken.readonlyThreshold !== '' ? Number(newToken.readonlyThreshold) : 1,
editorThreshold: newToken.editorThreshold !== null && newToken.editorThreshold !== undefined && newToken.editorThreshold !== '' ? Number(newToken.editorThreshold) : 2
};
console.log('[AuthTokensSettings] Отправляем данные токена:', tokenData);
console.log('[AuthTokensSettings] newToken объект:', newToken);
console.log('[AuthTokensSettings] newToken.readonlyThreshold:', newToken.readonlyThreshold, 'тип:', typeof newToken.readonlyThreshold);
console.log('[AuthTokensSettings] newToken.editorThreshold:', newToken.editorThreshold, 'тип:', typeof newToken.editorThreshold);
try { try {
await api.post('/settings/auth-token', { await api.post('/settings/auth-token', tokenData);
...newToken,
minBalance: Number(newToken.minBalance) || 0
});
// После добавления токена перепроверяем баланс пользователя и обновляем состояние аутентификации // После добавления токена перепроверяем баланс пользователя и обновляем состояние аутентификации
try { try {
if (address.value) { if (address.value) {
await checkTokenBalances(address.value); await checkTokenBalances(address.value);
console.log('[AuthTokensSettings] Баланс токенов перепроверен после добавления'); await checkUserAccessLevel(address.value);
console.log('[AuthTokensSettings] Баланс токенов и уровень доступа перепроверены после добавления');
} }
// Обновляем состояние аутентификации чтобы отразить изменения роли // Обновляем состояние аутентификации чтобы отразить изменения роли
@@ -138,6 +203,8 @@ async function addToken() {
newToken.address = ''; newToken.address = '';
newToken.network = ''; newToken.network = '';
newToken.minBalance = 0; newToken.minBalance = 0;
newToken.readonlyThreshold = 1;
newToken.editorThreshold = 2;
} catch (e) { } catch (e) {
alert('Ошибка при добавлении токена: ' + (e.response?.data?.error || e.message)); alert('Ошибка при добавлении токена: ' + (e.response?.data?.error || e.message));
} }
@@ -159,7 +226,8 @@ async function removeToken(index) {
try { try {
if (address.value) { if (address.value) {
await checkTokenBalances(address.value); await checkTokenBalances(address.value);
console.log('[AuthTokensSettings] Баланс токенов перепроверен после удаления'); await checkUserAccessLevel(address.value);
console.log('[AuthTokensSettings] Баланс токенов и уровень доступа перепроверены после удаления');
} }
// Обновляем состояние аутентификации чтобы отразить изменения роли // Обновляем состояние аутентификации чтобы отразить изменения роли
@@ -188,13 +256,118 @@ function getNetworkLabel(networkId) {
const found = networks.value.find(n => n.value === networkId); const found = networks.value.find(n => n.value === networkId);
return found ? found.label : networkId; return found ? found.label : networkId;
} }
function getAccessLevelDescription(level) {
switch (level) {
case 'readonly':
return 'Можете просматривать данные, но не можете редактировать или удалять';
case 'editor':
return 'Можете просматривать, редактировать и удалять данные';
case 'user':
default:
return 'Базовые права пользователя';
}
}
</script> </script>
<style scoped> <style scoped>
.tokens-list { margin-bottom: 1rem; } .tokens-list { margin-bottom: 1rem; }
.token-entry { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; } .token-entry {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.token-entry span {
font-size: 0.875rem;
white-space: nowrap;
}
.add-token-form { margin-top: 1rem; } .add-token-form { margin-top: 1rem; }
/* Стили для секции настроек прав доступа */
.access-settings {
margin-top: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.access-settings h6 {
margin-bottom: 1rem;
color: #495057;
font-weight: 600;
border-bottom: 1px solid #dee2e6;
padding-bottom: 0.5rem;
}
.form-text {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #6c757d;
}
/* Стили для отображения уровня доступа */
.access-level-info {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #007bff;
}
.access-level-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.access-level-badge i {
font-size: 1rem;
}
.access-readonly {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.access-editor {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.access-user {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.token-count {
font-weight: 400;
opacity: 0.8;
}
.access-level-description {
font-size: 0.85rem;
color: #6c757d;
margin-top: 0.25rem;
}
/* Стили для неактивных кнопок */ /* Стили для неактивных кнопок */
.btn[disabled], .btn:disabled { .btn[disabled], .btn:disabled {
background: #e0e0e0 !important; background: #e0e0e0 !important;

View File

@@ -858,7 +858,7 @@
@click="deploySmartContracts" @click="deploySmartContracts"
type="button" type="button"
class="btn btn-primary btn-lg deploy-btn" class="btn btn-primary btn-lg deploy-btn"
:disabled="!isFormValid || !adminTokenCheck.isAdmin || adminTokenCheck.isLoading || showDeployProgress" :disabled="!isFormValid || !canEdit || adminTokenCheck.isLoading || showDeployProgress"
:title="`isFormValid: ${isFormValid}, isAdmin: ${adminTokenCheck.isAdmin}, isLoading: ${adminTokenCheck.isLoading}, showDeployProgress: ${showDeployProgress}`" :title="`isFormValid: ${isFormValid}, isAdmin: ${adminTokenCheck.isAdmin}, isLoading: ${adminTokenCheck.isLoading}, showDeployProgress: ${showDeployProgress}`"
> >
<i class="fas fa-cogs"></i> <i class="fas fa-cogs"></i>
@@ -941,6 +941,7 @@
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import api from '@/api/axios'; import api from '@/api/axios';
import DeploymentWizard from '@/components/deployment/DeploymentWizard.vue'; import DeploymentWizard from '@/components/deployment/DeploymentWizard.vue';
@@ -959,6 +960,7 @@ function normalizePrivateKey(raw) {
// Получаем контекст авторизации для адреса кошелька // Получаем контекст авторизации для адреса кошелька
const { address, isAdmin } = useAuthContext(); const { address, isAdmin } = useAuthContext();
const { canEdit } = usePermissions();
// Состояние для проверки админских токенов // Состояние для проверки админских токенов
const adminTokenCheck = ref({ const adminTokenCheck = ref({

View File

@@ -33,8 +33,8 @@
</div> </div>
<button <button
class="btn-primary" class="btn-primary"
@click="isAdmin ? goToAkashDetails() : null" @click="canManageSettings ? goToAkashDetails() : null"
:disabled="!isAdmin" :disabled="!canManageSettings"
> >
Подробнее Подробнее
</button> </button>
@@ -54,8 +54,8 @@
</div> </div>
<button <button
class="btn-primary" class="btn-primary"
@click="isAdmin ? goToFluxDetails() : null" @click="canManageSettings ? goToFluxDetails() : null"
:disabled="!isAdmin" :disabled="!canManageSettings"
> >
Подробнее Подробнее
</button> </button>
@@ -91,10 +91,12 @@
<script setup> <script setup>
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import NoAccessModal from '@/components/NoAccessModal.vue'; import NoAccessModal from '@/components/NoAccessModal.vue';
import { ref } from 'vue'; import { ref } from 'vue';
const router = useRouter(); const router = useRouter();
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canManageSettings } = usePermissions();
const goBack = () => router.push('/settings'); const goBack = () => router.push('/settings');

View File

@@ -76,6 +76,7 @@ import RpcProvidersSettings from './RpcProvidersSettings.vue';
import AuthTokensSettings from './AuthTokensSettings.vue'; import AuthTokensSettings from './AuthTokensSettings.vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import NoAccessModal from '@/components/NoAccessModal.vue'; import NoAccessModal from '@/components/NoAccessModal.vue';
import wsClient from '@/utils/websocket'; import wsClient from '@/utils/websocket';
@@ -88,6 +89,7 @@ const showNoAccessModal = ref(false);
// Получаем контекст авторизации // Получаем контекст авторизации
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canManageSettings } = usePermissions();
// Настройки безопасности // Настройки безопасности
const securitySettings = reactive({ const securitySettings = reactive({
@@ -168,7 +170,9 @@ const loadSettings = async () => {
name: token.name, name: token.name,
address: token.address, address: token.address,
network: token.network, network: token.network,
minBalance: token.min_balance minBalance: token.min_balance,
readonlyThreshold: token.readonly_threshold || 1,
editorThreshold: token.editor_threshold || 2
})); }));
} }
@@ -331,11 +335,11 @@ provide('networks', networks);
// Функция для обработки клика по кнопке "Подробнее" для RPC провайдеров // Функция для обработки клика по кнопке "Подробнее" для RPC провайдеров
const handleRpcDetailsClick = () => { const handleRpcDetailsClick = () => {
if (isAdmin.value) { if (canManageSettings.value) {
// Если администратор - показываем детали RPC // Если есть права на управление настройками - показываем детали RPC
showRpcSettings.value = !showRpcSettings.value; showRpcSettings.value = !showRpcSettings.value;
} else { } else {
// Если обычный пользователь - показываем модальное окно с ограничением доступа // Если нет прав - показываем модальное окно с ограничением доступа
showNoAccessModal.value = true; showNoAccessModal.value = true;
} }
}; };

View File

@@ -27,7 +27,7 @@
<p v-else-if="isLoadingDle">Загрузка...</p> <p v-else-if="isLoadingDle">Загрузка...</p>
<p v-else>Подробная аналитика и статистика DLE</p> <p v-else>Подробная аналитика и статистика DLE</p>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Основная информация --> <!-- Основная информация -->
@@ -252,6 +252,15 @@ const route = useRoute();
// Получаем адрес DLE из URL параметров // Получаем адрес DLE из URL параметров
const dleAddress = ref(route.query.address || ''); const dleAddress = ref(route.query.address || '');
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (dleAddress.value) {
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
} else {
router.push('/management');
}
};
// Состояние // Состояние
const selectedDle = ref(null); const selectedDle = ref(null);
const isLoadingDle = ref(false); const isLoadingDle = ref(false);

View File

@@ -0,0 +1,298 @@
<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
This software is proprietary and confidential.
Unauthorized copying, modification, or distribution is prohibited.
For licensing inquiries: info@hb3-accelerator.com
Website: https://hb3-accelerator.com
GitHub: https://github.com/HB3-ACCELERATOR
-->
<template>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<div class="dle-blocks-management">
<!-- Заголовок -->
<div class="management-header">
<div class="header-content">
<h1>Управление DLE</h1>
<p v-if="dleAddress" class="dle-address">
<strong>DLE:</strong> {{ dleAddress }}
</p>
</div>
<button class="close-btn" @click="router.push('/management')">×</button>
</div>
<!-- Блоки управления -->
<div class="management-blocks">
<!-- Первый ряд -->
<div class="blocks-row">
<div class="management-block">
<h3>Предложения</h3>
<p>Создание, подписание, выполнение</p>
<button class="details-btn" @click="openProposals">Подробнее</button>
</div>
<div class="management-block">
<h3>Токены DLE</h3>
<p>Балансы, трансферы, распределение</p>
<button class="details-btn" @click="openTokens">Подробнее</button>
</div>
<div class="management-block">
<h3>Кворум</h3>
<p>Настройки голосования</p>
<button class="details-btn" @click="openQuorum">Подробнее</button>
</div>
</div>
<!-- Второй ряд -->
<div class="blocks-row">
<div class="management-block">
<h3>Модули DLE</h3>
<p>Установка, настройка, управление</p>
<button class="details-btn" @click="openModules">Подробнее</button>
</div>
<div class="management-block">
<h3>Аналитика</h3>
<p>Графики, статистика, отчеты</p>
<button class="details-btn" @click="openAnalytics">Подробнее</button>
</div>
</div>
<!-- Третий ряд -->
<div class="blocks-row">
<div class="management-block">
<h3>История</h3>
<p>Лог операций, события, транзакции</p>
<button class="details-btn" @click="openHistory">Подробнее</button>
</div>
<div class="management-block">
<h3>Настройки</h3>
<p>Параметры DLE, конфигурация</p>
<button class="details-btn" @click="openSettings">Подробнее</button>
</div>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
// Props
const props = defineProps({
isAuthenticated: Boolean,
identities: Array,
tokenBalances: Object,
isLoadingTokens: Boolean
});
const emit = defineEmits(['auth-action-completed']);
const router = useRouter();
const route = useRoute();
// Получаем адрес DLE из query параметров
const dleAddress = computed(() => route.query.address || null);
// Функции для открытия страниц управления
const openProposals = () => {
if (dleAddress.value) {
router.push(`/management/proposals?address=${dleAddress.value}`);
} else {
router.push('/management/proposals');
}
};
const openTokens = () => {
if (dleAddress.value) {
router.push(`/management/tokens?address=${dleAddress.value}`);
} else {
router.push('/management/tokens');
}
};
const openQuorum = () => {
if (dleAddress.value) {
router.push(`/management/quorum?address=${dleAddress.value}`);
} else {
router.push('/management/quorum');
}
};
const openModules = () => {
if (dleAddress.value) {
router.push(`/management/modules?address=${dleAddress.value}`);
} else {
router.push('/management/modules');
}
};
const openAnalytics = () => {
if (dleAddress.value) {
router.push(`/management/analytics?address=${dleAddress.value}`);
} else {
router.push('/management/analytics');
}
};
const openHistory = () => {
if (dleAddress.value) {
router.push(`/management/history?address=${dleAddress.value}`);
} else {
router.push('/management/history');
}
};
const openSettings = () => {
if (dleAddress.value) {
router.push(`/management/settings?address=${dleAddress.value}`);
} else {
router.push('/management/settings');
}
};
onMounted(() => {
// Если нет адреса DLE, перенаправляем на главную страницу management
if (!dleAddress.value) {
router.push('/management');
}
});
</script>
<style scoped>
.dle-blocks-management {
padding: 20px;
background-color: var(--color-white);
border-radius: var(--radius-lg);
min-height: 100vh;
}
.management-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.header-content h1 {
margin: 0;
color: var(--color-primary);
font-size: 2rem;
font-weight: 700;
}
.dle-address {
margin: 0.5rem 0 0 0;
color: #666;
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
color: #666;
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
transition: all 0.2s;
}
.close-btn:hover {
background: #f8f9fa;
color: #333;
}
.management-blocks {
display: flex;
flex-direction: column;
gap: 2rem;
}
.blocks-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.management-block {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
transition: all 0.3s ease;
text-align: center;
}
.management-block:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
border-color: var(--color-primary);
}
.management-block h3 {
margin: 0 0 1rem 0;
color: var(--color-primary);
font-size: 1.5rem;
font-weight: 600;
}
.management-block p {
margin: 0 0 1.5rem 0;
color: #666;
font-size: 1rem;
line-height: 1.5;
}
.details-btn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.2s;
min-width: 120px;
}
.details-btn:hover {
background: var(--color-primary-dark);
transform: translateY(-1px);
}
/* Адаптивность */
@media (max-width: 768px) {
.blocks-row {
grid-template-columns: 1fr;
}
.management-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
}
}
</style>

View File

@@ -27,7 +27,7 @@
<p v-else-if="isLoadingDle">Загрузка...</p> <p v-else-if="isLoadingDle">Загрузка...</p>
<p v-else>DLE не выбран</p> <p v-else>DLE не выбран</p>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Фильтры и управление --> <!-- Фильтры и управление -->
@@ -900,6 +900,15 @@ const dleAddress = computed(() => {
return address; return address;
}); });
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (dleAddress.value) {
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
} else {
router.push('/management');
}
};
// Состояние DLE // Состояние DLE
const selectedDle = ref(null); const selectedDle = ref(null);
const isLoadingDle = ref(false); const isLoadingDle = ref(false);

View File

@@ -27,7 +27,7 @@
<p v-else-if="isLoadingDle">Загрузка...</p> <p v-else-if="isLoadingDle">Загрузка...</p>
<p v-else>Лог операций, события и транзакции DLE</p> <p v-else>Лог операций, события и транзакции DLE</p>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Фильтры --> <!-- Фильтры -->
@@ -281,6 +281,15 @@ const route = useRoute();
// Получаем адрес DLE из URL параметров // Получаем адрес DLE из URL параметров
const dleAddress = ref(route.query.address || ''); const dleAddress = ref(route.query.address || '');
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (dleAddress.value) {
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
} else {
router.push('/management');
}
};
// Состояние // Состояние
const selectedDle = ref(null); const selectedDle = ref(null);
const isLoadingDle = ref(false); const isLoadingDle = ref(false);

View File

@@ -27,7 +27,7 @@
<p v-else-if="isLoadingDle">Загрузка...</p> <p v-else-if="isLoadingDle">Загрузка...</p>
<p v-else>DLE не выбран</p> <p v-else>DLE не выбран</p>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Информация о модулях --> <!-- Информация о модулях -->
@@ -665,6 +665,20 @@ const emit = defineEmits(['auth-action-completed']);
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
// Получаем адрес DLE из URL
const dleAddress = computed(() => {
return route.query.address;
});
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (dleAddress.value) {
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
} else {
router.push('/management');
}
};
// Состояние // Состояние
const selectedDle = ref(null); const selectedDle = ref(null);
const isLoadingDle = ref(false); const isLoadingDle = ref(false);

View File

@@ -25,7 +25,7 @@
<h1>Кворум</h1> <h1>Кворум</h1>
<p>Настройки голосования и кворума</p> <p>Настройки голосования и кворума</p>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Текущие настройки --> <!-- Текущие настройки -->
@@ -181,8 +181,8 @@
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits } from 'vue'; import { ref, computed, defineProps, defineEmits } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import { getGovernanceParams } from '../../services/dleV2Service.js'; import { getGovernanceParams } from '../../services/dleV2Service.js';
import { getQuorumAt, getVotingPowerAt } from '../../services/proposalsService.js'; import { getQuorumAt, getVotingPowerAt } from '../../services/proposalsService.js';
@@ -199,6 +199,21 @@ const props = defineProps({
const emit = defineEmits(['auth-action-completed']); const emit = defineEmits(['auth-action-completed']);
const router = useRouter(); const router = useRouter();
const route = useRoute();
// Получаем адрес DLE из URL
const dleAddress = computed(() => {
return route.query.address;
});
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (dleAddress.value) {
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
} else {
router.push('/management');
}
};
// Состояние // Состояние
const isUpdating = ref(false); const isUpdating = ref(false);

View File

@@ -27,7 +27,7 @@
<p v-else-if="address">Загрузка...</p> <p v-else-if="address">Загрузка...</p>
<p v-else>DLE не выбран</p> <p v-else>DLE не выбран</p>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Основной контент --> <!-- Основной контент -->
@@ -39,6 +39,15 @@
</div> </div>
<div class="danger-content"> <div class="danger-content">
<p>Полное удаление DLE и всех связанных данных. Это действие необратимо.</p> <p>Полное удаление DLE и всех связанных данных. Это действие необратимо.</p>
<div class="warning-info">
<h4> Важно:</h4>
<ul>
<li>Для деактивации DLE необходимо иметь токены</li>
<li>Может потребоваться голосование участников</li>
<li>DLE должен быть активен</li>
<li>Только владелец токенов может инициировать деактивацию</li>
</ul>
</div>
<button @click="deleteDLE" class="btn-danger" :disabled="isLoading"> <button @click="deleteDLE" class="btn-danger" :disabled="isLoading">
{{ isLoading ? 'Загрузка...' : 'Удалить DLE' }} {{ isLoading ? 'Загрузка...' : 'Удалить DLE' }}
</button> </button>
@@ -50,7 +59,7 @@
<div v-if="!address" class="no-dle-card"> <div v-if="!address" class="no-dle-card">
<h3>DLE не выбран</h3> <h3>DLE не выбран</h3>
<p>Для управления настройками необходимо выбрать DLE</p> <p>Для управления настройками необходимо выбрать DLE</p>
<button @click="router.push('/management')" class="btn-primary"> <button @click="goBackToBlocks" class="btn-primary">
Вернуться к списку DLE Вернуться к списку DLE
</button> </button>
</div> </div>
@@ -89,6 +98,15 @@ const isLoading = ref(false);
// Получаем адрес DLE из URL параметров // Получаем адрес DLE из URL параметров
const address = route.query.address || props.dleAddress; const address = route.query.address || props.dleAddress;
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (address) {
router.push(`/management/dle-blocks?address=${address}`);
} else {
router.push('/management');
}
};
// Получаем адрес пользователя из контекста аутентификации // Получаем адрес пользователя из контекста аутентификации
const { address: userAddress } = useAuthContext(); const { address: userAddress } = useAuthContext();
@@ -167,20 +185,26 @@ const deleteDLE = async () => {
alert(`✅ DLE ${dleInfo.value?.name || address} успешно деактивирован!\n\nТранзакция: ${result.txHash}`); alert(`✅ DLE ${dleInfo.value?.name || address} успешно деактивирован!\n\nТранзакция: ${result.txHash}`);
// Перенаправляем на главную страницу управления // Перенаправляем на страницу блоков управления
router.push('/management'); goBackToBlocks();
} catch (error) { } catch (error) {
console.error('Ошибка при деактивации DLE:', error); console.error('Ошибка при деактивации DLE:', error);
let errorMessage = 'Ошибка при деактивации DLE'; let errorMessage = 'Ошибка при деактивации DLE';
if (error.message.includes('владелец')) { if (error.message.includes('execution reverted')) {
errorMessage = '❌ Деактивация невозможна: не выполнены условия смарт-контракта. Возможно, требуется голосование участников или DLE уже деактивирован.';
} else if (error.message.includes('владелец')) {
errorMessage = '❌ Только владелец DLE может его деактивировать'; errorMessage = '❌ Только владелец DLE может его деактивировать';
} else if (error.message.includes('кошелек')) { } else if (error.message.includes('кошелек')) {
errorMessage = '❌ Необходимо подключить кошелек'; errorMessage = '❌ Необходимо подключить кошелек';
} else if (error.message.includes('деактивирован')) { } else if (error.message.includes('деактивирован')) {
errorMessage = '❌ DLE уже деактивирован'; errorMessage = '❌ DLE уже деактивирован';
} else if (error.message.includes('токены')) {
errorMessage = '❌ Для деактивации DLE необходимо иметь токены';
} else if (error.message.includes('условия смарт-контракта')) {
errorMessage = error.message; // Используем сообщение из dle-contract.js
} else { } else {
errorMessage = `❌ Ошибка: ${error.message}`; errorMessage = `❌ Ошибка: ${error.message}`;
} }
@@ -345,4 +369,31 @@ onMounted(() => {
line-height: 1.5; line-height: 1.5;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Стили для блока предупреждения */
.warning-info {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 15px;
margin: 15px 0;
}
.warning-info h4 {
color: #856404;
margin: 0 0 10px 0;
font-size: 1rem;
}
.warning-info ul {
margin: 0;
padding-left: 20px;
color: #856404;
}
.warning-info li {
margin-bottom: 5px;
font-size: 0.9rem;
line-height: 1.4;
}
</style> </style>

View File

@@ -35,7 +35,7 @@
<span>DLE не выбран</span> <span>DLE не выбран</span>
</div> </div>
</div> </div>
<button class="close-btn" @click="router.push('/management')">×</button> <button class="close-btn" @click="goBackToBlocks">×</button>
</div> </div>
<!-- Информация о токенах --> <!-- Информация о токенах -->
@@ -182,6 +182,15 @@ const dleAddress = computed(() => {
return address; return address;
}); });
// Функция возврата к блокам управления
const goBackToBlocks = () => {
if (dleAddress.value) {
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
} else {
router.push('/management');
}
};
// Состояние DLE // Состояние DLE
const selectedDle = ref(null); const selectedDle = ref(null);
const isLoadingDle = ref(false); const isLoadingDle = ref(false);

View File

@@ -49,11 +49,11 @@
</div> </div>
</div> </div>
<!-- Форма деплоя модуля во всех сетях --> <!-- Форма деплоя модуля администратором -->
<div class="deploy-form"> <div v-if="canManageSettings" class="deploy-form">
<div class="form-header"> <div class="form-header">
<h3>🌐 Деплой DLEReader во всех сетях</h3> <h3>🔧 Деплой DLEReader администратором</h3>
<p>Деплой API модуля для чтения данных во всех 4 сетях одновременно</p> <p>Администратор деплоит модуль, затем создает предложение для добавления в DLE</p>
</div> </div>
<div class="form-content"> <div class="form-content">
@@ -85,22 +85,39 @@
<h4> Настройки DLEReader:</h4> <h4> Настройки DLEReader:</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-group"> <!-- Поля администратора -->
<label for="chainId">ID сети:</label> <div class="admin-section">
<select <h5>🔐 Настройки администратора:</h5>
id="chainId"
v-model="moduleSettings.chainId" <div class="form-row">
class="form-control" <div class="form-group">
required <label for="adminPrivateKey">Приватный ключ администратора:</label>
> <input
<option value="11155111">Sepolia (11155111)</option> type="password"
<option value="17000">Holesky (17000)</option> id="adminPrivateKey"
<option value="421614">Arbitrum Sepolia (421614)</option> v-model="moduleSettings.adminPrivateKey"
<option value="84532">Base Sepolia (84532)</option> class="form-control"
</select> placeholder="0x..."
<small class="form-help">ID сети для деплоя модуля</small> required
>
<small class="form-help">Приватный ключ для деплоя модуля (администратор платит газ)</small>
</div>
<div class="form-group">
<label for="etherscanApiKey">Etherscan API ключ:</label>
<input
type="text"
id="etherscanApiKey"
v-model="moduleSettings.etherscanApiKey"
class="form-control"
placeholder="YourAPIKey..."
>
<small class="form-help">API ключ для автоматической верификации контрактов</small>
</div>
</div>
</div> </div>
<div class="simple-info"> <div class="simple-info">
<h5>📋 Информация о DLEReader:</h5> <h5>📋 Информация о DLEReader:</h5>
<div class="info-text"> <div class="info-text">
@@ -122,12 +139,17 @@
<button <button
class="btn btn-primary btn-large deploy-module" class="btn btn-primary btn-large deploy-module"
@click="deployDLEReader" @click="deployDLEReader"
:disabled="isDeploying || !dleAddress" :disabled="isDeploying || !dleAddress || !isFormValid"
> >
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i> <i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
{{ isDeploying ? 'Деплой модуля...' : 'Деплой DLEReader' }} {{ isDeploying ? 'Деплой модуля...' : 'Деплой DLEReader' }}
</button> </button>
<div v-if="!isFormValid && !isDeploying" class="form-validation-info">
<i class="fas fa-exclamation-triangle"></i>
<span>Заполните приватный ключ и API ключ для деплоя</span>
</div>
<div v-if="deploymentProgress" class="deployment-progress"> <div v-if="deploymentProgress" class="deployment-progress">
<div class="progress-info"> <div class="progress-info">
<span>{{ deploymentProgress.message }}</span> <span>{{ deploymentProgress.message }}</span>
@@ -141,14 +163,26 @@
</div> </div>
</div> </div>
<!-- Сообщение для пользователей без прав доступа -->
<div v-if="!canManageSettings" class="no-access-message">
<div class="message-content">
<h3>🔒 Нет прав доступа</h3>
<p>У вас нет прав для деплоя смарт-контрактов. Только пользователи с ролью Editor могут выполнять деплой.</p>
<button class="btn btn-secondary" @click="router.push('/management/modules')">
Вернуться к модулям
</button>
</div>
</div>
</div> </div>
</BaseLayout> </BaseLayout>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../../components/BaseLayout.vue'; import BaseLayout from '../../../components/BaseLayout.vue';
import { usePermissions } from '@/composables/usePermissions';
// Props // Props
const props = defineProps({ const props = defineProps({
@@ -162,6 +196,7 @@ const emit = defineEmits(['auth-action-completed']);
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const { canEdit, canManageSettings } = usePermissions();
// Состояние // Состояние
const isLoading = ref(false); const isLoading = ref(false);
@@ -171,12 +206,23 @@ const deploymentProgress = ref(null);
// Настройки модуля // Настройки модуля
const moduleSettings = ref({ const moduleSettings = ref({
// Единственный параметр - ID сети // Поля администратора
chainId: 11155111 adminPrivateKey: '',
etherscanApiKey: ''
});
// Проверка валидности формы
const isFormValid = computed(() => {
return moduleSettings.value.adminPrivateKey && moduleSettings.value.etherscanApiKey;
}); });
// Функция деплоя DLEReader // Функция деплоя DLEReader
async function deployDLEReader() { async function deployDLEReader() {
if (!canManageSettings.value) {
alert('У вас нет прав для деплоя смарт-контрактов');
return;
}
try { try {
isDeploying.value = true; isDeploying.value = true;
deploymentProgress.value = { deploymentProgress.value = {
@@ -186,8 +232,8 @@ async function deployDLEReader() {
console.log('[DLEReaderDeployView] Начинаем деплой DLEReader для DLE:', dleAddress.value); console.log('[DLEReaderDeployView] Начинаем деплой DLEReader для DLE:', dleAddress.value);
// Вызываем API для деплоя модуля во всех сетях // Вызываем API для деплоя модуля администратором
const response = await fetch('/api/dle-modules/deploy-reader', { const response = await fetch('/api/dle-modules/deploy-reader-admin', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -195,9 +241,11 @@ async function deployDLEReader() {
body: JSON.stringify({ body: JSON.stringify({
dleAddress: dleAddress.value, dleAddress: dleAddress.value,
moduleType: 'reader', moduleType: 'reader',
adminPrivateKey: moduleSettings.value.adminPrivateKey,
etherscanApiKey: moduleSettings.value.etherscanApiKey,
settings: { settings: {
// Единственный параметр - ID сети // Используем настройки по умолчанию
chainId: moduleSettings.value.chainId useDefaultSettings: true
} }
}) })
}); });
@@ -217,12 +265,31 @@ async function deployDLEReader() {
percentage: 100 percentage: 100
}; };
alert('✅ Деплой DLEReader запущен во всех сетях!'); // Показываем детальную информацию о деплое
const deployInfo = result.data || {};
const deployedAddresses = deployInfo.addresses || [];
let successMessage = '✅ DLEReader успешно задеплоен!\n\n';
successMessage += `📊 Детали деплоя:\n`;
successMessage += `• DLE: ${dleAddress.value}\n`;
successMessage += `• Тип модуля: DLEReader\n`;
successMessage += `• Адрес модуля: ${deployInfo.moduleAddress || 'Не указан'}\n`;
if (deployedAddresses.length > 0) {
successMessage += `\n🌐 Задеплоенные адреса:\n`;
deployedAddresses.forEach((addr, index) => {
successMessage += `${index + 1}. ${addr.network}: ${addr.address}\n`;
});
}
successMessage += `\n📝 Следующий шаг: Создайте предложение для добавления модуля в DLE через governance.`;
alert(successMessage);
// Перенаправляем обратно к модулям // Перенаправляем обратно к модулям
setTimeout(() => { setTimeout(() => {
router.push(`/management/modules?address=${dleAddress.value}`); router.push(`/management/modules?address=${dleAddress.value}`);
}, 2000); }, 3000);
} else { } else {
throw new Error(result.error || 'Неизвестная ошибка'); throw new Error(result.error || 'Неизвестная ошибка');
@@ -440,6 +507,22 @@ onMounted(() => {
margin-bottom: 0; margin-bottom: 0;
} }
/* Секция администратора */
.admin-section {
margin-bottom: 20px;
padding: 20px;
background: #fff3cd;
border-radius: var(--radius-sm);
border: 1px solid #ffeaa7;
}
.admin-section h5 {
margin: 0 0 15px 0;
color: #856404;
font-size: 1.1rem;
font-weight: 600;
}
/* Простая информация */ /* Простая информация */
.simple-info { .simple-info {
margin-top: 20px; margin-top: 20px;
@@ -582,4 +665,43 @@ onMounted(() => {
color: #666; color: #666;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
} }
/* Сообщение об отсутствии прав доступа */
.no-access-message {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: var(--radius-md);
padding: 30px;
margin: 20px 0;
text-align: center;
}
.message-content h3 {
color: #856404;
margin-bottom: 15px;
font-size: 1.4em;
}
.message-content p {
color: #856404;
margin-bottom: 20px;
font-size: 1.1em;
line-height: 1.5;
}
.message-content .btn {
background: #6c757d;
color: white;
border: none;
border-radius: var(--radius-sm);
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.message-content .btn:hover {
background: #5a6268;
}
</style> </style>

View File

@@ -49,11 +49,11 @@
</div> </div>
</div> </div>
<!-- Форма деплоя модуля во всех сетях --> <!-- Форма деплоя модуля администратором -->
<div class="deploy-form"> <div v-if="canManageSettings" class="deploy-form">
<div class="form-header"> <div class="form-header">
<h3>🌐 Деплой TimelockModule во всех сетях</h3> <h3>🔧 Деплой TimelockModule администратором</h3>
<p>Деплой модуля временных задержек во всех 4 сетях одновременно</p> <p>Администратор деплоит модуль, затем создает предложение для добавления в DLE</p>
</div> </div>
<div class="form-content"> <div class="form-content">
@@ -85,193 +85,49 @@
<h4> Настройки TimelockModule:</h4> <h4> Настройки TimelockModule:</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-row"> <!-- Поля администратора -->
<div class="form-group"> <div class="admin-section">
<label for="chainId">ID сети:</label> <h5>🔐 Настройки администратора:</h5>
<select
id="chainId"
v-model="moduleSettings.chainId"
class="form-control"
required
>
<option value="11155111">Sepolia (11155111)</option>
<option value="17000">Holesky (17000)</option>
<option value="421614">Arbitrum Sepolia (421614)</option>
<option value="84532">Base Sepolia (84532)</option>
</select>
<small class="form-help">ID сети для деплоя модуля</small>
</div>
<div class="form-group">
<label for="defaultDelay">Стандартная задержка (дни):</label>
<input
type="number"
id="defaultDelay"
v-model="moduleSettings.defaultDelay"
class="form-control"
min="1"
max="30"
placeholder="2"
>
<small class="form-help">Стандартная задержка для операций (1-30 дней)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="emergencyDelay">Экстренная задержка (минуты):</label>
<input
type="number"
id="emergencyDelay"
v-model="moduleSettings.emergencyDelay"
class="form-control"
min="5"
max="1440"
placeholder="30"
>
<small class="form-help">Экстренная задержка для критических операций (5-1440 минут)</small>
</div>
<div class="form-group">
<label for="maxDelay">Максимальная задержка (дни):</label>
<input
type="number"
id="maxDelay"
v-model="moduleSettings.maxDelay"
class="form-control"
min="1"
max="365"
placeholder="30"
>
<small class="form-help">Максимальная задержка для операций (1-365 дней)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="minDelay">Минимальная задержка (часы):</label>
<input
type="number"
id="minDelay"
v-model="moduleSettings.minDelay"
class="form-control"
min="1"
max="720"
placeholder="24"
>
<small class="form-help">Минимальная задержка для операций (1-720 часов)</small>
</div>
<div class="form-group">
<label for="maxOperations">Максимум операций в очереди:</label>
<input
type="number"
id="maxOperations"
v-model="moduleSettings.maxOperations"
class="form-control"
min="10"
max="1000"
placeholder="100"
>
<small class="form-help">Максимальное количество операций в очереди (10-1000)</small>
</div>
</div>
<!-- Дополнительные настройки таймлока -->
<div class="advanced-settings">
<h5>🔧 Дополнительные настройки таймлока:</h5>
<div class="form-group">
<label for="criticalOperations">Критические операции (JSON формат):</label>
<textarea
id="criticalOperations"
v-model="moduleSettings.criticalOperations"
class="form-control"
rows="3"
placeholder='["0x12345678", "0x87654321"]'
></textarea>
<small class="form-help">Селекторы функций, которые считаются критическими (JSON массив)</small>
</div>
<div class="form-group">
<label for="emergencyOperations">Экстренные операции (JSON формат):</label>
<textarea
id="emergencyOperations"
v-model="moduleSettings.emergencyOperations"
class="form-control"
rows="3"
placeholder='["0xabcdef12", "0x21fedcba"]'
></textarea>
<small class="form-help">Селекторы функций для экстренных операций (JSON массив)</small>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="operationDelays">Задержки для операций (JSON формат):</label> <label for="adminPrivateKey">Приватный ключ администратора:</label>
<textarea <input
id="operationDelays" type="password"
v-model="moduleSettings.operationDelays" id="adminPrivateKey"
v-model="moduleSettings.adminPrivateKey"
class="form-control" class="form-control"
rows="4" placeholder="0x..."
placeholder='{"0x12345678": 86400, "0x87654321": 172800}' required
></textarea> >
<small class="form-help">Кастомные задержки для конкретных операций (селектор => секунды)</small> <small class="form-help">Приватный ключ для деплоя модуля (администратор платит газ)</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="autoExecuteEnabled">Автоисполнение включено:</label> <label for="etherscanApiKey">Etherscan API ключ:</label>
<select <input
id="autoExecuteEnabled" type="text"
v-model="moduleSettings.autoExecuteEnabled" id="etherscanApiKey"
v-model="moduleSettings.etherscanApiKey"
class="form-control" class="form-control"
placeholder="YourAPIKey..."
> >
<option value="true">Включено</option> <small class="form-help">API ключ для автоматической верификации контрактов</small>
<option value="false">Отключено</option>
</select>
<small class="form-help">Автоматическое исполнение операций после истечения задержки</small>
</div> </div>
</div> </div>
</div>
<div class="form-row"> <div class="simple-info">
<div class="form-group"> <h5>📋 Информация о TimelockModule:</h5>
<label for="cancellationWindow">Окно отмены (часы):</label> <div class="info-text">
<input <p><strong>TimelockModule</strong> будет задеплоен с настройками по умолчанию:</p>
type="number" <ul>
id="cancellationWindow" <li> Стандартная задержка: 2 дня</li>
v-model="moduleSettings.cancellationWindow" <li> Экстренная задержка: 30 минут</li>
class="form-control" <li> Автоматическое исполнение операций</li>
min="1" <li> Готовые настройки безопасности</li>
max="168" </ul>
placeholder="24" <p><em>После деплоя настройки можно будет изменить через governance.</em></p>
>
<small class="form-help">Время, в течение которого можно отменить операцию (1-168 часов)</small>
</div>
<div class="form-group">
<label for="executionWindow">Окно исполнения (часы):</label>
<input
type="number"
id="executionWindow"
v-model="moduleSettings.executionWindow"
class="form-control"
min="1"
max="168"
placeholder="48"
>
<small class="form-help">Время, в течение которого можно исполнить операцию (1-168 часов)</small>
</div>
</div>
<div class="form-group">
<label for="timelockDescription">Описание таймлока:</label>
<textarea
id="timelockDescription"
v-model="moduleSettings.timelockDescription"
class="form-control"
rows="2"
placeholder="Описание таймлока DLE для безопасности операций..."
></textarea>
<small class="form-help">Описание таймлока для документации</small>
</div> </div>
</div> </div>
</div> </div>
@@ -282,12 +138,17 @@
<button <button
class="btn btn-primary btn-large deploy-module" class="btn btn-primary btn-large deploy-module"
@click="deployTimelockModule" @click="deployTimelockModule"
:disabled="isDeploying || !dleAddress" :disabled="isDeploying || !dleAddress || !isFormValid"
> >
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i> <i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
{{ isDeploying ? 'Деплой модуля...' : 'Деплой TimelockModule' }} {{ isDeploying ? 'Деплой модуля...' : 'Деплой TimelockModule' }}
</button> </button>
<div v-if="!isFormValid && !isDeploying" class="form-validation-info">
<i class="fas fa-exclamation-triangle"></i>
<span>Заполните приватный ключ и API ключ для деплоя</span>
</div>
<div v-if="deploymentProgress" class="deployment-progress"> <div v-if="deploymentProgress" class="deployment-progress">
<div class="progress-info"> <div class="progress-info">
<span>{{ deploymentProgress.message }}</span> <span>{{ deploymentProgress.message }}</span>
@@ -301,14 +162,26 @@
</div> </div>
</div> </div>
<!-- Сообщение для пользователей без прав доступа -->
<div v-if="!canManageSettings" class="no-access-message">
<div class="message-content">
<h3>🔒 Нет прав доступа</h3>
<p>У вас нет прав для деплоя смарт-контрактов. Только пользователи с ролью Editor могут выполнять деплой.</p>
<button class="btn btn-secondary" @click="router.push('/management/modules')">
Вернуться к модулям
</button>
</div>
</div>
</div> </div>
</BaseLayout> </BaseLayout>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, onMounted } from 'vue'; import { defineProps, defineEmits, ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../../components/BaseLayout.vue'; import BaseLayout from '../../../components/BaseLayout.vue';
import { usePermissions } from '@/composables/usePermissions';
// Определяем props // Определяем props
const props = defineProps({ const props = defineProps({
@@ -323,6 +196,7 @@ const emit = defineEmits(['auth-action-completed']);
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const { canEdit, canManageSettings } = usePermissions();
// Состояние // Состояние
const isLoading = ref(false); const isLoading = ref(false);
@@ -332,26 +206,23 @@ const deploymentProgress = ref(null);
// Настройки модуля // Настройки модуля
const moduleSettings = ref({ const moduleSettings = ref({
// Основные параметры // Поля администратора
chainId: 11155111, adminPrivateKey: '',
defaultDelay: 2, // days etherscanApiKey: ''
emergencyDelay: 30, // minutes });
maxDelay: 30, // days
minDelay: 24, // hours
// Дополнительные настройки // Проверка валидности формы
maxOperations: 100, const isFormValid = computed(() => {
criticalOperations: '', return moduleSettings.value.adminPrivateKey && moduleSettings.value.etherscanApiKey;
emergencyOperations: '',
operationDelays: '',
autoExecuteEnabled: 'true',
cancellationWindow: 24, // hours
executionWindow: 48, // hours
timelockDescription: ''
}); });
// Функция деплоя TimelockModule // Функция деплоя TimelockModule
async function deployTimelockModule() { async function deployTimelockModule() {
if (!canManageSettings.value) {
alert('У вас нет прав для деплоя смарт-контрактов');
return;
}
try { try {
isDeploying.value = true; isDeploying.value = true;
deploymentProgress.value = { deploymentProgress.value = {
@@ -361,8 +232,8 @@ async function deployTimelockModule() {
console.log('[TimelockModuleDeployView] Начинаем деплой TimelockModule для DLE:', dleAddress.value); console.log('[TimelockModuleDeployView] Начинаем деплой TimelockModule для DLE:', dleAddress.value);
// Вызываем API для деплоя модуля во всех сетях // Вызываем API для деплоя модуля администратором
const response = await fetch('/api/dle-modules/deploy-timelock', { const response = await fetch('/api/dle-modules/deploy-timelock-admin', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -370,23 +241,11 @@ async function deployTimelockModule() {
body: JSON.stringify({ body: JSON.stringify({
dleAddress: dleAddress.value, dleAddress: dleAddress.value,
moduleType: 'timelock', moduleType: 'timelock',
adminPrivateKey: moduleSettings.value.adminPrivateKey,
etherscanApiKey: moduleSettings.value.etherscanApiKey,
settings: { settings: {
// Основные параметры // Используем настройки по умолчанию
chainId: moduleSettings.value.chainId, useDefaultSettings: true
defaultDelay: moduleSettings.value.defaultDelay * 24 * 60 * 60, // конвертируем дни в секунды
emergencyDelay: moduleSettings.value.emergencyDelay * 60, // конвертируем минуты в секунды
maxDelay: moduleSettings.value.maxDelay * 24 * 60 * 60, // конвертируем дни в секунды
minDelay: moduleSettings.value.minDelay * 60 * 60, // конвертируем часы в секунды
// Дополнительные настройки
maxOperations: parseInt(moduleSettings.value.maxOperations),
criticalOperations: moduleSettings.value.criticalOperations ? JSON.parse(moduleSettings.value.criticalOperations) : [],
emergencyOperations: moduleSettings.value.emergencyOperations ? JSON.parse(moduleSettings.value.emergencyOperations) : [],
operationDelays: moduleSettings.value.operationDelays ? JSON.parse(moduleSettings.value.operationDelays) : {},
autoExecuteEnabled: moduleSettings.value.autoExecuteEnabled === 'true',
cancellationWindow: moduleSettings.value.cancellationWindow * 60 * 60, // конвертируем часы в секунды
executionWindow: moduleSettings.value.executionWindow * 60 * 60, // конвертируем часы в секунды
timelockDescription: moduleSettings.value.timelockDescription
} }
}) })
}); });
@@ -406,12 +265,31 @@ async function deployTimelockModule() {
percentage: 100 percentage: 100
}; };
alert('✅ Деплой TimelockModule запущен во всех сетях!'); // Показываем детальную информацию о деплое
const deployInfo = result.data || {};
const deployedAddresses = deployInfo.addresses || [];
let successMessage = '✅ TimelockModule успешно задеплоен!\n\n';
successMessage += `📊 Детали деплоя:\n`;
successMessage += `• DLE: ${dleAddress.value}\n`;
successMessage += `• Тип модуля: TimelockModule\n`;
successMessage += `• Адрес модуля: ${deployInfo.moduleAddress || 'Не указан'}\n`;
if (deployedAddresses.length > 0) {
successMessage += `\n🌐 Задеплоенные адреса:\n`;
deployedAddresses.forEach((addr, index) => {
successMessage += `${index + 1}. ${addr.network}: ${addr.address}\n`;
});
}
successMessage += `\n📝 Следующий шаг: Создайте предложение для добавления модуля в DLE через governance.`;
alert(successMessage);
// Перенаправляем обратно к модулям // Перенаправляем обратно к модулям
setTimeout(() => { setTimeout(() => {
router.push(`/management/modules?address=${dleAddress.value}`); router.push(`/management/modules?address=${dleAddress.value}`);
}, 2000); }, 3000);
} else { } else {
throw new Error(result.error || 'Неизвестная ошибка'); throw new Error(result.error || 'Неизвестная ошибка');
@@ -569,6 +447,22 @@ onMounted(() => {
line-height: 1.4; line-height: 1.4;
} }
/* Секция администратора */
.admin-section {
margin-bottom: 20px;
padding: 20px;
background: #fff3cd;
border-radius: var(--radius-sm);
border: 1px solid #ffeaa7;
}
.admin-section h5 {
margin: 0 0 15px 0;
color: #856404;
font-size: 1.1rem;
font-weight: 600;
}
/* Дополнительные настройки */ /* Дополнительные настройки */
.advanced-settings { .advanced-settings {
margin-top: 20px; margin-top: 20px;
@@ -725,6 +619,45 @@ onMounted(() => {
margin: 0; margin: 0;
} }
/* Сообщение об отсутствии прав доступа */
.no-access-message {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: var(--radius-md);
padding: 30px;
margin: 20px 0;
text-align: center;
}
.message-content h3 {
color: #856404;
margin-bottom: 15px;
font-size: 1.4em;
}
.message-content p {
color: #856404;
margin-bottom: 20px;
font-size: 1.1em;
line-height: 1.5;
}
.message-content .btn {
background: #6c757d;
color: white;
border: none;
border-radius: var(--radius-sm);
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.message-content .btn:hover {
background: #5a6268;
}
/* Адаптивность */ /* Адаптивность */
@media (max-width: 768px) { @media (max-width: 768px) {
.info-grid { .info-grid {

View File

@@ -49,11 +49,11 @@
</div> </div>
</div> </div>
<!-- Форма деплоя модуля во всех сетях --> <!-- Форма деплоя модуля администратором -->
<div class="deploy-form"> <div v-if="canManageSettings" class="deploy-form">
<div class="form-header"> <div class="form-header">
<h3>🌐 Деплой TreasuryModule во всех сетях</h3> <h3>🌐 Деплой TreasuryModule во всех сетях</h3>
<p>Деплой модуля казначейства во всех 4 сетях одновременно</p> <p>Администратор деплоит модуль во всех 4 сетях одновременно, затем создает предложение для добавления в DLE</p>
</div> </div>
<div class="form-content"> <div class="form-content">
@@ -85,203 +85,52 @@
<h4> Настройки TreasuryModule:</h4> <h4> Настройки TreasuryModule:</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-row"> <!-- Поля администратора -->
<div class="form-group"> <div class="admin-section">
<label for="emergencyAdmin">Адрес экстренного администратора:</label> <h5>🔐 Настройки администратора:</h5>
<input
type="text"
id="emergencyAdmin"
v-model="moduleSettings.emergencyAdmin"
class="form-control"
placeholder="0x..."
required
>
<small class="form-help">Адрес экстренного администратора для управления модулем</small>
</div>
<div class="form-group">
<label for="chainId">ID сети:</label>
<select
id="chainId"
v-model="moduleSettings.chainId"
class="form-control"
required
>
<option value="11155111">Sepolia (11155111)</option>
<option value="17000">Holesky (17000)</option>
<option value="421614">Arbitrum Sepolia (421614)</option>
<option value="84532">Base Sepolia (84532)</option>
</select>
<small class="form-help">ID сети для деплоя модуля</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="defaultDelay">Стандартная задержка (часы):</label>
<input
type="number"
id="defaultDelay"
v-model="moduleSettings.defaultDelay"
class="form-control"
min="1"
max="720"
placeholder="24"
>
<small class="form-help">Стандартная задержка для операций (1-720 часов)</small>
</div>
<div class="form-group">
<label for="emergencyDelay">Экстренная задержка (минуты):</label>
<input
type="number"
id="emergencyDelay"
v-model="moduleSettings.emergencyDelay"
class="form-control"
min="5"
max="1440"
placeholder="30"
>
<small class="form-help">Экстренная задержка для критических операций (5-1440 минут)</small>
</div>
</div>
<div class="form-group">
<label for="supportedTokens">Поддерживаемые токены (адреса через запятую):</label>
<textarea
id="supportedTokens"
v-model="moduleSettings.supportedTokens"
class="form-control"
rows="3"
placeholder="0x1234..., 0x5678..., 0x9abc..."
></textarea>
<small class="form-help">Адреса ERC20 токенов, которые будет поддерживать казначейство (через запятую)</small>
</div>
<div class="form-group">
<label for="gasPaymentTokens">Токены для оплаты газа (адреса через запятую):</label>
<textarea
id="gasPaymentTokens"
v-model="moduleSettings.gasPaymentTokens"
class="form-control"
rows="2"
placeholder="0x1234..., 0x5678..."
></textarea>
<small class="form-help">Токены, которыми можно оплачивать газ (через запятую)</small>
</div>
<!-- Дополнительные настройки казны -->
<div class="advanced-settings">
<h5>🔧 Дополнительные настройки казны:</h5>
<div class="form-row">
<div class="form-group">
<label for="paymasterAddress">Адрес Paymaster:</label>
<input
type="text"
id="paymasterAddress"
v-model="moduleSettings.paymasterAddress"
class="form-control"
placeholder="0x..."
>
<small class="form-help">Адрес Paymaster для ERC-4337 (оплата газа любым токеном)</small>
</div>
<div class="form-group">
<label for="maxBatchTransfers">Максимум batch переводов:</label>
<input
type="number"
id="maxBatchTransfers"
v-model="moduleSettings.maxBatchTransfers"
class="form-control"
min="1"
max="100"
placeholder="50"
>
<small class="form-help">Максимальное количество переводов в batch операции (1-100)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="gasTokenRates">Курсы токенов для газа (JSON формат):</label>
<textarea
id="gasTokenRates"
v-model="moduleSettings.gasTokenRates"
class="form-control"
rows="3"
placeholder='{"0x1234...": "1000000000000000000", "0x5678...": "2000000000000000000"}'
></textarea>
<small class="form-help">Курсы обмена токенов на нативную монету (JSON формат)</small>
</div>
<div class="form-group">
<label for="emergencyThreshold">Порог экстренных операций (ETH):</label>
<input
type="number"
id="emergencyThreshold"
v-model="moduleSettings.emergencyThreshold"
class="form-control"
min="0"
step="0.001"
placeholder="1.0"
>
<small class="form-help">Порог для экстренных операций в ETH</small>
</div>
</div>
<div class="form-group">
<label for="initialTokens">Начальные токены для добавления (JSON формат):</label>
<textarea
id="initialTokens"
v-model="moduleSettings.initialTokens"
class="form-control"
rows="4"
placeholder='[{"address": "0x1234...", "symbol": "USDC", "decimals": 6}, {"address": "0x5678...", "symbol": "USDT", "decimals": 6}]'
></textarea>
<small class="form-help">Токены для автоматического добавления при деплое (JSON массив)</small>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="autoRefreshBalances">Автообновление балансов:</label> <label for="adminPrivateKey">Приватный ключ администратора:</label>
<select <input
id="autoRefreshBalances" type="password"
v-model="moduleSettings.autoRefreshBalances" id="adminPrivateKey"
v-model="moduleSettings.adminPrivateKey"
class="form-control" class="form-control"
placeholder="0x..."
required
> >
<option value="true">Включено</option> <small class="form-help">Приватный ключ для деплоя модуля (администратор платит газ)</small>
<option value="false">Отключено</option>
</select>
<small class="form-help">Автоматическое обновление балансов токенов</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="batchTransferEnabled">Batch переводы включены:</label> <label for="etherscanApiKey">Etherscan API ключ:</label>
<select <input
id="batchTransferEnabled" type="text"
v-model="moduleSettings.batchTransferEnabled" id="etherscanApiKey"
v-model="moduleSettings.etherscanApiKey"
class="form-control" class="form-control"
placeholder="YourAPIKey..."
> >
<option value="true">Включено</option> <small class="form-help">API ключ для автоматической верификации контрактов</small>
<option value="false">Отключено</option>
</select>
<small class="form-help">Разрешить batch операции переводов</small>
</div> </div>
</div> </div>
</div>
<div class="form-group"> <div class="simple-info">
<label for="treasuryDescription">Описание казны:</label> <h5>📋 Информация о TreasuryModule:</h5>
<textarea <div class="info-text">
id="treasuryDescription" <p><strong>TreasuryModule</strong> будет задеплоен с настройками по умолчанию:</p>
v-model="moduleSettings.treasuryDescription" <ul>
class="form-control" <li> Поддержка ETH и основных ERC20 токенов</li>
rows="2" <li> Стандартные задержки для безопасности</li>
placeholder="Описание казны DLE для управления финансами..." <li> Автоматическая настройка для всех сетей</li>
></textarea> <li> Готовые настройки безопасности</li>
<small class="form-help">Описание казны для документации</small> </ul>
<p><em>После деплоя настройки можно будет изменить через governance.</em></p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -290,12 +139,17 @@
<button <button
class="btn btn-primary btn-large deploy-module" class="btn btn-primary btn-large deploy-module"
@click="deployTreasuryModule" @click="deployTreasuryModule"
:disabled="isDeploying || !dleAddress" :disabled="isDeploying || !dleAddress || !isFormValid"
> >
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i> <i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
{{ isDeploying ? 'Деплой модуля...' : 'Деплой TreasuryModule' }} {{ isDeploying ? 'Деплой во всех сетях...' : 'Деплой TreasuryModule во всех сетях' }}
</button> </button>
<div v-if="!isFormValid && !isDeploying" class="form-validation-info">
<i class="fas fa-exclamation-triangle"></i>
<span>Заполните приватный ключ и API ключ для деплоя</span>
</div>
<div v-if="deploymentProgress" class="deployment-progress"> <div v-if="deploymentProgress" class="deployment-progress">
<div class="progress-info"> <div class="progress-info">
<span>{{ deploymentProgress.message }}</span> <span>{{ deploymentProgress.message }}</span>
@@ -309,14 +163,26 @@
</div> </div>
</div> </div>
<!-- Сообщение для пользователей без прав доступа -->
<div v-if="!canManageSettings" class="no-access-message">
<div class="message-content">
<h3>🔒 Нет прав доступа</h3>
<p>У вас нет прав для деплоя смарт-контрактов. Только пользователи с ролью Editor могут выполнять деплой.</p>
<button class="btn btn-secondary" @click="router.push('/management/modules')">
Вернуться к модулям
</button>
</div>
</div>
</div> </div>
</BaseLayout> </BaseLayout>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, onMounted } from 'vue'; import { defineProps, defineEmits, ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../../components/BaseLayout.vue'; import BaseLayout from '../../../components/BaseLayout.vue';
import { usePermissions } from '@/composables/usePermissions';
// Определяем props // Определяем props
const props = defineProps({ const props = defineProps({
@@ -331,6 +197,7 @@ const emit = defineEmits(['auth-action-completed']);
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const { canEdit, canManageSettings } = usePermissions();
// Состояние // Состояние
const isLoading = ref(false); const isLoading = ref(false);
@@ -340,29 +207,23 @@ const deploymentProgress = ref(null);
// Настройки модуля // Настройки модуля
const moduleSettings = ref({ const moduleSettings = ref({
// Основные параметры // Поля администратора
emergencyAdmin: '', adminPrivateKey: '',
chainId: 11155111, etherscanApiKey: ''
defaultDelay: 24, // hours });
emergencyDelay: 30, // minutes
// Токены // Проверка валидности формы
supportedTokens: '', const isFormValid = computed(() => {
gasPaymentTokens: '', return moduleSettings.value.adminPrivateKey && moduleSettings.value.etherscanApiKey;
initialTokens: '',
// Дополнительные настройки
paymasterAddress: '',
maxBatchTransfers: 50,
gasTokenRates: '',
emergencyThreshold: 1.0,
autoRefreshBalances: 'true',
batchTransferEnabled: 'true',
treasuryDescription: ''
}); });
// Функция деплоя TreasuryModule // Функция деплоя TreasuryModule
async function deployTreasuryModule() { async function deployTreasuryModule() {
if (!canManageSettings.value) {
alert('У вас нет прав для деплоя смарт-контрактов');
return;
}
try { try {
isDeploying.value = true; isDeploying.value = true;
deploymentProgress.value = { deploymentProgress.value = {
@@ -372,8 +233,8 @@ async function deployTreasuryModule() {
console.log('[TreasuryModuleDeployView] Начинаем деплой TreasuryModule для DLE:', dleAddress.value); console.log('[TreasuryModuleDeployView] Начинаем деплой TreasuryModule для DLE:', dleAddress.value);
// Вызываем API для деплоя модуля во всех сетях // Вызываем API для деплоя модуля администратором
const response = await fetch('/api/dle-modules/deploy-treasury', { const response = await fetch('/api/dle-modules/deploy-treasury-admin', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -381,26 +242,11 @@ async function deployTreasuryModule() {
body: JSON.stringify({ body: JSON.stringify({
dleAddress: dleAddress.value, dleAddress: dleAddress.value,
moduleType: 'treasury', moduleType: 'treasury',
adminPrivateKey: moduleSettings.value.adminPrivateKey,
etherscanApiKey: moduleSettings.value.etherscanApiKey,
settings: { settings: {
// Основные параметры // Используем настройки по умолчанию
emergencyAdmin: moduleSettings.value.emergencyAdmin, useDefaultSettings: true
chainId: moduleSettings.value.chainId,
defaultDelay: moduleSettings.value.defaultDelay,
emergencyDelay: moduleSettings.value.emergencyDelay,
// Токены
supportedTokens: moduleSettings.value.supportedTokens.split(',').map(addr => addr.trim()).filter(addr => addr),
gasPaymentTokens: moduleSettings.value.gasPaymentTokens.split(',').map(addr => addr.trim()).filter(addr => addr),
initialTokens: moduleSettings.value.initialTokens ? JSON.parse(moduleSettings.value.initialTokens) : [],
// Дополнительные настройки
paymasterAddress: moduleSettings.value.paymasterAddress,
maxBatchTransfers: parseInt(moduleSettings.value.maxBatchTransfers),
gasTokenRates: moduleSettings.value.gasTokenRates ? JSON.parse(moduleSettings.value.gasTokenRates) : {},
emergencyThreshold: parseFloat(moduleSettings.value.emergencyThreshold),
autoRefreshBalances: moduleSettings.value.autoRefreshBalances === 'true',
batchTransferEnabled: moduleSettings.value.batchTransferEnabled === 'true',
treasuryDescription: moduleSettings.value.treasuryDescription
} }
}) })
}); });
@@ -420,12 +266,31 @@ async function deployTreasuryModule() {
percentage: 100 percentage: 100
}; };
alert('✅ Деплой TreasuryModule запущен во всех сетях!'); // Показываем детальную информацию о деплое
const deployInfo = result.data || {};
const deployedAddresses = deployInfo.addresses || [];
let successMessage = '✅ TreasuryModule успешно задеплоен во всех сетях!\n\n';
successMessage += `📊 Детали деплоя:\n`;
successMessage += `• DLE: ${dleAddress.value}\n`;
successMessage += `• Тип модуля: TreasuryModule\n`;
successMessage += `• Сети: Sepolia, Holesky, Arbitrum Sepolia, Base Sepolia\n`;
if (deployedAddresses.length > 0) {
successMessage += `\n🌐 Задеплоенные адреса:\n`;
deployedAddresses.forEach((addr, index) => {
successMessage += `${index + 1}. ${addr.network}: ${addr.address}\n`;
});
}
successMessage += `\n📝 Следующий шаг: Создайте предложение для добавления модуля в DLE через governance.`;
alert(successMessage);
// Перенаправляем обратно к модулям // Перенаправляем обратно к модулям
setTimeout(() => { setTimeout(() => {
router.push(`/management/modules?address=${dleAddress.value}`); router.push(`/management/modules?address=${dleAddress.value}`);
}, 2000); }, 3000);
} else { } else {
throw new Error(result.error || 'Неизвестная ошибка'); throw new Error(result.error || 'Неизвестная ошибка');
@@ -583,6 +448,176 @@ onMounted(() => {
line-height: 1.4; line-height: 1.4;
} }
/* Секция администратора */
.admin-section {
margin-bottom: 20px;
padding: 20px;
background: #fff3cd;
border-radius: var(--radius-sm);
border: 1px solid #ffeaa7;
}
.admin-section h5 {
margin: 0 0 15px 0;
color: #856404;
font-size: 1.1rem;
font-weight: 600;
}
/* Простая информация */
.simple-info {
margin-top: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: var(--radius-sm);
border: 1px solid #e9ecef;
}
.simple-info h5 {
margin: 0 0 15px 0;
color: var(--color-primary);
font-size: 1.1rem;
font-weight: 600;
}
.info-text {
color: #666;
line-height: 1.6;
}
.info-text ul {
margin: 10px 0;
padding-left: 20px;
}
.info-text li {
margin-bottom: 5px;
}
.token-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.token-input-wrapper {
position: relative;
flex: 1;
}
.token-input {
width: 100%;
padding-right: 40px;
}
.token-input.is-valid {
border-color: #28a745;
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.1);
}
.token-input.is-invalid {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.1);
}
.validation-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 10px;
}
.validation-icon.valid {
background: #28a745;
color: white;
}
.validation-icon.invalid {
background: #dc3545;
color: white;
}
.validation-icon.loading {
background: #6c757d;
color: white;
}
.remove-token {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #dc3545;
border: none;
color: white;
font-size: 12px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.remove-token:hover {
background: #c82333;
transform: scale(1.05);
}
.remove-token:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
}
.add-token {
margin-top: 15px;
width: 100%;
padding: 12px 20px;
border: 2px dashed #28a745;
background: #f8f9fa;
color: #28a745;
border-radius: var(--radius-sm);
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.add-token:hover {
background: #28a745;
color: white;
border-color: #28a745;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.2);
}
/* Сообщение валидации */
.form-validation-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
padding: 10px 15px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: var(--radius-sm);
color: #856404;
font-size: 14px;
}
.form-validation-info i {
color: #f39c12;
}
/* Дополнительные настройки */ /* Дополнительные настройки */
.advanced-settings { .advanced-settings {
margin-top: 20px; margin-top: 20px;
@@ -797,6 +832,45 @@ onMounted(() => {
margin: 0; margin: 0;
} }
/* Сообщение об отсутствии прав доступа */
.no-access-message {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: var(--radius-md);
padding: 30px;
margin: 20px 0;
text-align: center;
}
.message-content h3 {
color: #856404;
margin-bottom: 15px;
font-size: 1.4em;
}
.message-content p {
color: #856404;
margin-bottom: 20px;
font-size: 1.1em;
line-height: 1.5;
}
.message-content .btn {
background: #6c757d;
color: white;
border: none;
border-radius: var(--radius-sm);
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.message-content .btn:hover {
background: #5a6268;
}
/* Адаптивность */ /* Адаптивность */
@media (max-width: 768px) { @media (max-width: 768px) {
.info-grid { .info-grid {

View File

@@ -14,7 +14,7 @@
<BaseLayout> <BaseLayout>
<div class="create-table-container"> <div class="create-table-container">
<h2>Создать новую таблицу</h2> <h2>Создать новую таблицу</h2>
<form v-if="isAdmin" @submit.prevent="handleCreateTable" class="create-table-form"> <form v-if="canEdit" @submit.prevent="handleCreateTable" class="create-table-form">
<label>Название таблицы</label> <label>Название таблицы</label>
<input v-model="newTableName" required placeholder="Введите название" /> <input v-model="newTableName" required placeholder="Введите название" />
<label>Описание</label> <label>Описание</label>
@@ -29,7 +29,10 @@
<button type="button" @click="goBack">Отмена</button> <button type="button" @click="goBack">Отмена</button>
</div> </div>
</form> </form>
<div v-else class="empty-table-placeholder">Нет прав для создания таблицы</div> <div v-else class="empty-table-placeholder">
<p>Нет прав для создания таблицы</p>
<button type="button" @click="goBack" class="btn btn-primary">Назад</button>
</div>
</div> </div>
</BaseLayout> </BaseLayout>
</template> </template>
@@ -40,12 +43,14 @@ import { useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
const router = useRouter(); const router = useRouter();
const newTableName = ref(''); const newTableName = ref('');
const newTableDescription = ref(''); const newTableDescription = ref('');
const newTableIsRagSourceId = ref(2); const newTableIsRagSourceId = ref(2);
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canEdit } = usePermissions();
async function handleCreateTable() { async function handleCreateTable() {
if (!newTableName.value) return; if (!newTableName.value) return;
@@ -128,4 +133,30 @@ function goBack() {
.form-actions button[type="button"]:hover { .form-actions button[type="button"]:hover {
background: #d5d5d5; background: #d5d5d5;
} }
.empty-table-placeholder {
text-align: center;
padding: 2em;
color: #666;
}
.empty-table-placeholder p {
margin-bottom: 1.5em;
font-size: 1.1em;
}
.empty-table-placeholder .btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.empty-table-placeholder .btn:hover {
background: #27ae38;
}
</style> </style>

View File

@@ -16,10 +16,10 @@
<h2>Удалить таблицу?</h2> <h2>Удалить таблицу?</h2>
<p>Вы уверены, что хотите удалить эту таблицу? Это действие необратимо.</p> <p>Вы уверены, что хотите удалить эту таблицу? Это действие необратимо.</p>
<div class="actions"> <div class="actions">
<button v-if="isAdmin" class="danger" @click="remove">Удалить</button> <button v-if="canDelete" class="danger" @click="remove">Удалить</button>
<button @click="cancel">Отмена</button> <button @click="cancel">Отмена</button>
</div> </div>
<div v-if="!isAdmin" class="empty-table-placeholder">Нет прав для удаления таблицы</div> <div v-if="!canDelete" class="empty-table-placeholder">Нет прав для удаления таблицы</div>
</div> </div>
</BaseLayout> </BaseLayout>
</template> </template>
@@ -28,9 +28,11 @@ import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import axios from 'axios'; import axios from 'axios';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
const $route = useRoute(); const $route = useRoute();
const router = useRouter(); const router = useRouter();
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canDelete } = usePermissions();
async function remove() { async function remove() {
await axios.delete(`/tables/${$route.params.id}`); await axios.delete(`/tables/${$route.params.id}`);

View File

@@ -17,10 +17,10 @@
<button class="nav-btn" @click="goToTables">Таблицы</button> <button class="nav-btn" @click="goToTables">Таблицы</button>
<button class="nav-btn" @click="goToCreate">Создать таблицу</button> <button class="nav-btn" @click="goToCreate">Создать таблицу</button>
<button class="close-btn" @click="closeTable">Закрыть</button> <button class="close-btn" @click="closeTable">Закрыть</button>
<button v-if="isAdmin" class="action-btn" @click="goToEdit">Редактировать</button> <button v-if="canEdit" class="action-btn" @click="goToEdit">Редактировать</button>
<button v-if="isAdmin" class="danger-btn" @click="goToDelete">Удалить</button> <button v-if="canDelete" class="danger-btn" @click="goToDelete">Удалить</button>
</div> </div>
<UserTableView v-if="isAdmin" :table-id="Number($route.params.id)" /> <UserTableView v-if="canRead" :table-id="Number($route.params.id)" />
<div v-else class="empty-table-placeholder">Нет данных для отображения</div> <div v-else class="empty-table-placeholder">Нет данных для отображения</div>
</div> </div>
</BaseLayout> </BaseLayout>
@@ -31,9 +31,11 @@ import BaseLayout from '../../components/BaseLayout.vue';
import UserTableView from '../../components/tables/UserTableView.vue'; import UserTableView from '../../components/tables/UserTableView.vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
const $route = useRoute(); const $route = useRoute();
const router = useRouter(); const router = useRouter();
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canRead, canEdit, canDelete } = usePermissions();
function closeTable() { function closeTable() {
if (window.history.length > 1) { if (window.history.length > 1) {

View File

@@ -15,7 +15,7 @@
<div class="tables-list-block"> <div class="tables-list-block">
<button class="close-btn" @click="goBack">×</button> <button class="close-btn" @click="goBack">×</button>
<h2>Список таблиц</h2> <h2>Список таблиц</h2>
<UserTablesList v-if="isAdmin" /> <UserTablesList v-if="canRead" />
<div v-else class="empty-table-placeholder">Нет данных для отображения</div> <div v-else class="empty-table-placeholder">Нет данных для отображения</div>
</div> </div>
</BaseLayout> </BaseLayout>
@@ -26,8 +26,10 @@ import BaseLayout from '../../components/BaseLayout.vue';
import UserTablesList from '../../components/tables/UserTablesList.vue'; import UserTablesList from '../../components/tables/UserTablesList.vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
const router = useRouter(); const router = useRouter();
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const { canRead } = usePermissions();
function goBack() { function goBack() {
router.push({ name: 'crm' }); router.push({ name: 'crm' });
} }