Add multilingual support with translations for German, Russian, French, and Chinese

Added time limits and backup functions for servers
This commit is contained in:
infosave2007
2025-11-08 09:14:20 +03:00
parent 1deea2e4b7
commit 1f91f17f57
25 changed files with 2494 additions and 103 deletions
+5
View File
@@ -48,6 +48,11 @@ FLUSH PRIVILEGES;
USE amnezia_panel; USE amnezia_panel;
SOURCE migrations/001_init.sql; SOURCE migrations/001_init.sql;
SOURCE migrations/002_translations_ru.sql;
SOURCE migrations/003_translations_es.sql;
SOURCE migrations/004_translations_de.sql;
SOURCE migrations/005_translations_fr.sql;
SOURCE migrations/006_translations_zh.sql;
``` ```
6. **Update Database Config** 6. **Update Database Config**
+68 -5
View File
@@ -5,12 +5,14 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers.
## Features ## Features
- VPN server deployment via SSH - VPN server deployment via SSH
- Client configuration management - Client configuration management with **expiration dates**
- **Server backup and restore** functionality
- Traffic statistics monitoring - Traffic statistics monitoring
- QR code generation for mobile apps - QR code generation for mobile apps
- Multi-language interface (English, Russian, Spanish, German, French, Chinese) - Multi-language interface (English, Russian, Spanish, German, French, Chinese)
- REST API with JWT authentication - REST API with JWT authentication
- User authentication and access control - User authentication and access control
- **Automatic client expiration check** via cron
## Requirements ## Requirements
@@ -61,8 +63,54 @@ JWT_SECRET=your-secret-key-change-this
1. Open server details 1. Open server details
2. Enter client name 2. Enter client name
3. Click Create Client 3. **Select expiration period** (optional, default: never expires)
4. Download config or scan QR code 4. Click Create Client
5. Download config or scan QR code
### Manage Client Expiration
Set expiration via UI or API:
```bash
# Set specific date
curl -X POST http://localhost:8082/api/clients/123/set-expiration \
-H "Authorization: Bearer <token>" \
-d '{"expires_at": "2025-12-31 23:59:59"}'
# Extend by 30 days
curl -X POST http://localhost:8082/api/clients/123/extend \
-H "Authorization: Bearer <token>" \
-d '{"days": 30}'
# Get expiring clients (within 7 days)
curl http://localhost:8082/api/clients/expiring?days=7 \
-H "Authorization: Bearer <token>"
```
### Server Backups
Create and restore backups via UI or API:
```bash
# Create backup
curl -X POST http://localhost:8082/api/servers/1/backup \
-H "Authorization: Bearer <token>"
# List backups
curl http://localhost:8082/api/servers/1/backups \
-H "Authorization: Bearer <token>"
# Restore from backup
curl -X POST http://localhost:8082/api/servers/1/restore \
-H "Authorization: Bearer <token>" \
-d '{"backup_id": 123}'
```
### Automatic Client Expiration Check
Run via cron to auto-disable expired clients:
```bash
# Add to crontab (runs every hour)
0 * * * * docker compose exec web php /var/www/html/bin/check_expired_clients.php
```
### API Authentication ### API Authentication
@@ -103,10 +151,25 @@ GET /api/clients - List all clients
GET /api/clients/{id}/details - Get client details with stats, config and QR code GET /api/clients/{id}/details - Get client details with stats, config and QR code
GET /api/clients/{id}/qr - Get client QR code GET /api/clients/{id}/qr - Get client QR code
POST /api/clients/create - Create new client (returns config and QR code) POST /api/clients/create - Create new client (returns config and QR code)
Parameters: server_id, name Parameters: server_id, name, expires_in_days (optional)
POST /api/clients/{id}/revoke - Revoke client access POST /api/clients/{id}/revoke - Revoke client access
POST /api/clients/{id}/restore - Restore client access POST /api/clients/{id}/restore - Restore client access
DELETE /api/clients/{id}/delete - Delete client by ID DELETE /api/clients/{id}/delete - Delete client by ID
POST /api/clients/{id}/set-expiration - Set client expiration date
Parameters: expires_at (Y-m-d H:i:s or null)
POST /api/clients/{id}/extend - Extend client expiration
Parameters: days (int)
GET /api/clients/expiring - Get clients expiring soon
Parameters: days (default: 7)
```
### Backups
```
POST /api/servers/{id}/backup - Create server backup
GET /api/servers/{id}/backups - List server backups
POST /api/servers/{id}/restore - Restore from backup
Parameters: backup_id
DELETE /api/backups/{id} - Delete backup
``` ```
## Translation ## Translation
@@ -133,7 +196,7 @@ inc/ - Core classes
JWT.php - Token auth JWT.php - Token auth
QrUtil.php - QR code generation QrUtil.php - QR code generation
templates/ - Twig templates templates/ - Twig templates
migrations/ - SQL migrations migrations/ - SQL migrations (executed in alphabetical order)
``` ```
## Tech Stack ## Tech Stack
+29
View File
@@ -0,0 +1,29 @@
{
"server": {
"name": "NL Server1",
"host": "62.204.42.184",
"port": 22,
"vpn_port": 64966,
"vpn_subnet": "10.8.1.0\/24",
"container_name": "amnezia-awg",
"server_public_key": "AFgZ21KZdOmopdpg5EP6g7rroGFpPOlGNXVZ8sh7JFY=",
"preshared_key": "QdeZLyd5\/eZoC8Iz5MAGu6lrEX2dp04Rq6+s2UhefnA=",
"awg_params": "{\"H1\": 700691355, \"H2\": 416510699, \"H3\": 1626278027, \"H4\": 1657157387, \"Jc\": 3, \"S1\": 117, \"S2\": 177, \"Jmax\": 50, \"Jmin\": 10}"
},
"clients": [
{
"id": 1,
"name": "oleg",
"client_ip": "10.8.1.1",
"public_key": "U3N6bUhl+Wfqe7dB+Rn9vu1A16nz2cz\/9WgFiJoHMS0=",
"private_key": "gMjQq1E8vD76ZEuA+si\/Xd5FiA1mkSBjUsjc8d4+lFw=",
"preshared_key": "QdeZLyd5\/eZoC8Iz5MAGu6lrEX2dp04Rq6+s2UhefnA=",
"config": "[Interface]\nPrivateKey = gMjQq1E8vD76ZEuA+si\/Xd5FiA1mkSBjUsjc8d4+lFw=\nAddress = 10.8.1.1\/32\nDNS = 1.1.1.1, 1.0.0.1\nJc = 3\nJmin = 10\nJmax = 50\nS1 = 117\nS2 = 177\nH1 = 700691355\nH2 = 416510699\nH3 = 1626278027\nH4 = 1657157387\n\n[Peer]\nPublicKey = AFgZ21KZdOmopdpg5EP6g7rroGFpPOlGNXVZ8sh7JFY=\nPresharedKey = QdeZLyd5\/eZoC8Iz5MAGu6lrEX2dp04Rq6+s2UhefnA=\nEndpoint = 62.204.42.184:64966\nAllowedIPs = 0.0.0.0\/0, ::\/0\nPersistentKeepalive = 25\n",
"status": "active",
"expires_at": null,
"created_at": "2025-11-08 05:46:15"
}
],
"backup_date": "2025-11-08 06:06:24",
"version": "1.0"
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env php
<?php
/**
* Check and disable expired VPN clients
* Run this script via cron to automatically disable clients that have expired
*
* Example cron: 0 * * * * /usr/bin/php /var/www/html/bin/check_expired_clients.php
*/
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../inc/Config.php';
require __DIR__ . '/../inc/DB.php';
require __DIR__ . '/../inc/VpnClient.php';
require __DIR__ . '/../inc/VpnServer.php';
// Load environment
Config::load(__DIR__ . '/../.env');
try {
echo "[" . date('Y-m-d H:i:s') . "] Checking for expired clients...\n";
// Disable all expired clients
$count = VpnClient::disableExpiredClients();
if ($count > 0) {
echo "[" . date('Y-m-d H:i:s') . "] Disabled {$count} expired client(s)\n";
} else {
echo "[" . date('Y-m-d H:i:s') . "] No expired clients found\n";
}
// Report expiring clients (within 7 days)
$expiring = VpnClient::getExpiringClients(7);
if (count($expiring) > 0) {
echo "[" . date('Y-m-d H:i:s') . "] " . count($expiring) . " client(s) expiring soon:\n";
foreach ($expiring as $client) {
$daysLeft = (int)floor((strtotime($client['expires_at']) - time()) / 86400);
echo " - {$client['name']} ({$client['email']}) expires in {$daysLeft} day(s)\n";
}
}
exit(0);
} catch (Exception $e) {
echo "[" . date('Y-m-d H:i:s') . "] ERROR: " . $e->getMessage() . "\n";
exit(1);
}
+63 -31
View File
@@ -176,6 +176,7 @@ class SettingsController {
$service = $_POST['service'] ?? ''; $service = $_POST['service'] ?? '';
$apiKey = trim($_POST['api_key'] ?? ''); $apiKey = trim($_POST['api_key'] ?? '');
$skipTest = isset($_POST['skip_test']); // Allow saving without testing
if (empty($service) || empty($apiKey)) { if (empty($service) || empty($apiKey)) {
View::render('settings.twig', [ View::render('settings.twig', [
@@ -185,22 +186,20 @@ class SettingsController {
return; return;
} }
// Validate OpenRouter key format // Test the API key (unless skip_test is set)
if ($service === 'openrouter' && !preg_match('/^sk-or-v1-[a-zA-Z0-9]{64,}$/', $apiKey)) { if ($service === 'openrouter' && !$skipTest) {
View::render('settings.twig', [
'error' => $this->translator->translate('settings.error_invalid_key'),
'translation_stats' => $this->getTranslationStats()
]);
return;
}
// Test the API key
if ($service === 'openrouter') {
$testResult = $this->testOpenRouterKey($apiKey); $testResult = $this->testOpenRouterKey($apiKey);
if (!$testResult['success']) { if (!$testResult['success']) {
// If rate limited, suggest saving without test
$errorMsg = $this->translator->translate('settings.error_key_test') . ': ' . $testResult['error'];
if (strpos($testResult['error'], '429') !== false || strpos($testResult['error'], 'Rate limit') !== false) {
$errorMsg .= ' - You can save without testing by checking "Skip validation"';
}
View::render('settings.twig', [ View::render('settings.twig', [
'error' => $this->translator->translate('settings.error_key_test') . ': ' . $testResult['error'], 'error' => $errorMsg,
'translation_stats' => $this->getTranslationStats() 'translation_stats' => $this->getTranslationStats(),
'openrouter_key' => ''
]); ]);
return; return;
} }
@@ -210,35 +209,32 @@ class SettingsController {
$saved = $this->translator->saveApiKey($service, $apiKey); $saved = $this->translator->saveApiKey($service, $apiKey);
if ($saved) { if ($saved) {
View::render('settings.twig', [ $_SESSION['settings_success'] = $this->translator->translate('settings.key_saved');
'success' => $this->translator->translate('settings.key_saved'), header('Location: /settings#api');
'translation_stats' => $this->getTranslationStats(), exit;
'openrouter_key' => '' // Don't show the saved key
]);
} else { } else {
View::render('settings.twig', [ $_SESSION['settings_error'] = $this->translator->translate('message.error');
'error' => $this->translator->translate('message.error'), header('Location: /settings#api');
'translation_stats' => $this->getTranslationStats() exit;
]);
} }
} }
private function testOpenRouterKey($apiKey) { private function testOpenRouterKey($apiKey) {
// Test with a simple translation request // Test with a simple request to check API key validity
$url = 'https://openrouter.ai/api/v1/chat/completions'; $url = 'https://openrouter.ai/api/v1/chat/completions';
$data = [ $data = [
'model' => 'google/gemini-2.0-flash-exp:free', 'model' => 'openai/gpt-4o-mini',
'messages' => [ 'messages' => [
['role' => 'user', 'content' => 'Translate "test" to Spanish. Reply only with the translation.'] ['role' => 'user', 'content' => 'Reply with: OK']
], ],
'temperature' => 0.3, 'max_tokens' => 5
'max_tokens' => 10
]; ];
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey, 'Authorization: Bearer ' . $apiKey,
@@ -248,19 +244,55 @@ class SettingsController {
$response = curl_exec($ch); $response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch); curl_close($ch);
if ($httpCode === 200) { // Handle cURL errors
if ($curlError) {
return [
'success' => false,
'error' => 'Network error: ' . $curlError
];
}
// Parse response
$result = json_decode($response, true); $result = json_decode($response, true);
if (isset($result['choices'][0]['message']['content'])) {
// Success - got a valid response
if ($httpCode === 200 && isset($result['choices'][0]['message'])) {
return ['success' => true]; return ['success' => true];
} }
// Extract error message from various formats
$errorMsg = 'Unknown error';
if (isset($result['error'])) {
if (is_string($result['error'])) {
$errorMsg = $result['error'];
} elseif (isset($result['error']['message'])) {
$errorMsg = $result['error']['message'];
} elseif (isset($result['error']['code'])) {
$errorMsg = 'Error code: ' . $result['error']['code'];
}
}
// Add HTTP code if not 200
if ($httpCode !== 200) {
$errorMsg .= ' (HTTP ' . $httpCode . ')';
}
// Common error messages user-friendly translations
if (strpos($errorMsg, 'No auth credentials') !== false || $httpCode === 401) {
$errorMsg = 'Invalid API key or authentication failed';
} elseif (strpos($errorMsg, 'insufficient_quota') !== false || strpos($errorMsg, 'quota') !== false) {
$errorMsg = 'API quota exceeded or no credits available';
} elseif (strpos($errorMsg, 'rate_limit') !== false) {
$errorMsg = 'Rate limit exceeded, try again later';
} }
$error = json_decode($response, true);
return [ return [
'success' => false, 'success' => false,
'error' => $error['error']['message'] ?? 'Unknown error (HTTP ' . $httpCode . ')' 'error' => $errorMsg
]; ];
} }
+2
View File
@@ -3,6 +3,7 @@ services:
image: mysql:8.0 image: mysql:8.0
container_name: amnezia-panel-db container_name: amnezia-panel-db
restart: unless-stopped restart: unless-stopped
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-authentication-plugin=mysql_native_password
env_file: env_file:
- .env - .env
environment: environment:
@@ -15,6 +16,7 @@ services:
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
- ./migrations:/docker-entrypoint-initdb.d - ./migrations:/docker-entrypoint-initdb.d
- ./my.cnf:/etc/mysql/conf.d/my.cnf:ro
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
+4
View File
@@ -16,6 +16,10 @@ class DB {
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
]; ];
self::$pdo = new PDO($dsn, $user, $pass, $options); self::$pdo = new PDO($dsn, $user, $pass, $options);
// Explicitly set UTF-8 encoding for connection
self::$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
return self::$pdo; return self::$pdo;
} }
} }
+9 -7
View File
@@ -196,11 +196,11 @@ class Translator {
* Translate text using AI with model fallback * Translate text using AI with model fallback
*/ */
private static function translateWithAI(string $text, string $targetLanguage): ?string { private static function translateWithAI(string $text, string $targetLanguage): ?string {
// Try multiple free models for reliability // Use reliable paid models with fallback
$models = [ $models = [
'google/gemini-2.0-flash-exp:free', 'anthropic/claude-3.5-sonnet',
'meta-llama/llama-3.2-3b-instruct:free', 'openai/gpt-4o-mini',
'qwen/qwen-2-7b-instruct:free' 'google/gemini-pro-1.5'
]; ];
foreach ($models as $model) { foreach ($models as $model) {
@@ -350,9 +350,10 @@ class Translator {
foreach ($missingKeys as $key => $value) { foreach ($missingKeys as $key => $value) {
if (self::autoTranslate($targetLang, $key, $value)) { if (self::autoTranslate($targetLang, $key, $value)) {
$stats['translated']++; $stats['translated']++;
usleep(500000); // 500ms delay between requests sleep(3); // 3 second delay between requests to avoid rate limits
} else { } else {
$stats['failed']++; $stats['failed']++;
sleep(2); // Also delay on failure
} }
} }
@@ -390,8 +391,9 @@ class Translator {
$jsonTexts = json_encode($textsForJson, JSON_UNESCAPED_UNICODE); $jsonTexts = json_encode($textsForJson, JSON_UNESCAPED_UNICODE);
$models = [ $models = [
'google/gemini-2.0-flash-exp:free', 'anthropic/claude-3.5-sonnet',
'meta-llama/llama-3.2-3b-instruct:free' 'openai/gpt-4o-mini',
'google/gemini-pro-1.5'
]; ];
foreach ($models as $model) { foreach ($models as $model) {
+145 -4
View File
@@ -30,8 +30,14 @@ class VpnClient {
/** /**
* Create new VPN client * Create new VPN client
*
* @param int $serverId Server ID
* @param int $userId User ID
* @param string $name Client name
* @param int|null $expiresInDays Days until expiration (null = never expires)
* @return int Client ID
*/ */
public static function create(int $serverId, int $userId, string $name): int { public static function create(int $serverId, int $userId, string $name, ?int $expiresInDays = null): int {
$pdo = DB::conn(); $pdo = DB::conn();
// Get server data // Get server data
@@ -69,11 +75,14 @@ class VpnClient {
// Generate QR code // Generate QR code
$qrCode = self::generateQRCode($config); $qrCode = self::generateQRCode($config);
// Calculate expiration date
$expiresAt = $expiresInDays ? date('Y-m-d H:i:s', strtotime("+{$expiresInDays} days")) : null;
// Insert into database // Insert into database
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
INSERT INTO vpn_clients INSERT INTO vpn_clients
(server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status) (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'); ');
$stmt->execute([ $stmt->execute([
@@ -86,7 +95,8 @@ class VpnClient {
$serverData['preshared_key'], $serverData['preshared_key'],
$config, $config,
$qrCode, $qrCode,
'active' 'active',
$expiresAt
]); ]);
return (int)$pdo->lastInsertId(); return (int)$pdo->lastInsertId();
@@ -685,4 +695,135 @@ class VpnClient {
return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i]; return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i];
} }
/**
* Set client expiration date
*
* @param int $clientId Client ID
* @param string|null $expiresAt Expiration date (Y-m-d H:i:s) or null for never expires
* @return bool Success
*/
public static function setExpiration(int $clientId, ?string $expiresAt): bool {
$pdo = DB::conn();
$stmt = $pdo->prepare('UPDATE vpn_clients SET expires_at = ? WHERE id = ?');
return $stmt->execute([$expiresAt, $clientId]);
} }
/**
* Extend client expiration by days
*
* @param int $clientId Client ID
* @param int $days Days to extend
* @return bool Success
*/
public static function extendExpiration(int $clientId, int $days): bool {
$pdo = DB::conn();
// Get current expiration
$stmt = $pdo->prepare('SELECT expires_at FROM vpn_clients WHERE id = ?');
$stmt->execute([$clientId]);
$client = $stmt->fetch();
if (!$client) {
return false;
}
// Calculate new expiration from current or now
$baseDate = $client['expires_at'] ? strtotime($client['expires_at']) : time();
$newExpiration = date('Y-m-d H:i:s', strtotime("+{$days} days", $baseDate));
return self::setExpiration($clientId, $newExpiration);
}
/**
* Get clients expiring soon
*
* @param int $days Check for clients expiring within N days
* @return array List of expiring clients
*/
public static function getExpiringClients(int $days = 7): array {
$pdo = DB::conn();
$stmt = $pdo->prepare('
SELECT c.*, s.name as server_name, s.host, u.name as user_name, u.email
FROM vpn_clients c
JOIN vpn_servers s ON c.server_id = s.id
JOIN users u ON c.user_id = u.id
WHERE c.expires_at IS NOT NULL
AND c.expires_at <= DATE_ADD(NOW(), INTERVAL ? DAY)
AND c.expires_at > NOW()
AND c.status = "active"
ORDER BY c.expires_at ASC
');
$stmt->execute([$days]);
return $stmt->fetchAll();
}
/**
* Get expired clients
*
* @return array List of expired clients
*/
public static function getExpiredClients(): array {
$pdo = DB::conn();
$stmt = $pdo->query('
SELECT c.*, s.name as server_name, s.host
FROM vpn_clients c
JOIN vpn_servers s ON c.server_id = s.id
WHERE c.expires_at IS NOT NULL
AND c.expires_at <= NOW()
AND c.status = "active"
ORDER BY c.expires_at DESC
');
return $stmt->fetchAll();
}
/**
* Disable expired clients automatically
*
* @return int Number of clients disabled
*/
public static function disableExpiredClients(): int {
$expiredClients = self::getExpiredClients();
$count = 0;
foreach ($expiredClients as $clientData) {
try {
$client = new self($clientData['id']);
$client->revoke();
$count++;
} catch (Exception $e) {
error_log("Failed to disable expired client {$clientData['id']}: " . $e->getMessage());
}
}
return $count;
}
/**
* Check if client is expired
*
* @return bool True if expired
*/
public function isExpired(): bool {
if (!$this->data) {
return false;
}
return $this->data['expires_at'] !== null && strtotime($this->data['expires_at']) <= time();
}
/**
* Get days until expiration
*
* @return int|null Days until expiration (negative if expired, null if never expires)
*/
public function getDaysUntilExpiration(): ?int {
if (!$this->data || $this->data['expires_at'] === null) {
return null;
}
$diff = strtotime($this->data['expires_at']) - time();
return (int)floor($diff / 86400);
}
}
+262
View File
@@ -446,4 +446,266 @@ BASH;
public function getData(): ?array { public function getData(): ?array {
return $this->data; return $this->data;
} }
/**
* Create backup of server configuration and all clients
*
* @param int $userId User who creates the backup
* @param string $backupType Type: 'manual' or 'automatic'
* @return int Backup ID
*/
public function createBackup(int $userId, string $backupType = 'manual'): int {
if (!$this->data) {
throw new Exception('Server not loaded');
}
$pdo = DB::conn();
$backupName = 'backup_' . $this->serverId . '_' . date('Y-m-d_His') . '.json';
$backupDir = '/var/www/html/backups';
$backupPath = $backupDir . '/' . $backupName;
// Create backups directory if not exists
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
try {
// Get all clients for this server
$stmt = $pdo->prepare('
SELECT id, name, client_ip, public_key, private_key, preshared_key,
config, status, expires_at, created_at
FROM vpn_clients
WHERE server_id = ?
');
$stmt->execute([$this->serverId]);
$clients = $stmt->fetchAll();
// Prepare backup data
$backupData = [
'server' => [
'name' => $this->data['name'],
'host' => $this->data['host'],
'port' => $this->data['port'],
'vpn_port' => $this->data['vpn_port'],
'vpn_subnet' => $this->data['vpn_subnet'],
'container_name' => $this->data['container_name'],
'server_public_key' => $this->data['server_public_key'],
'preshared_key' => $this->data['preshared_key'],
'awg_params' => $this->data['awg_params'],
],
'clients' => $clients,
'backup_date' => date('Y-m-d H:i:s'),
'version' => '1.0'
];
// Write backup to file
$json = json_encode($backupData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
file_put_contents($backupPath, $json);
$backupSize = filesize($backupPath);
// Insert backup record
$stmt = $pdo->prepare('
INSERT INTO server_backups
(server_id, backup_name, backup_path, backup_size, clients_count, backup_type, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([
$this->serverId,
$backupName,
$backupPath,
$backupSize,
count($clients),
$backupType,
'completed',
$userId
]);
return (int)$pdo->lastInsertId();
} catch (Exception $e) {
// Mark backup as failed
if (isset($stmt)) {
$stmt = $pdo->prepare('
INSERT INTO server_backups
(server_id, backup_name, backup_path, backup_type, status, error_message, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([
$this->serverId,
$backupName,
$backupPath,
$backupType,
'failed',
$e->getMessage(),
$userId
]);
}
throw $e;
}
}
/**
* List all backups for this server
*
* @return array List of backups
*/
public function listBackups(): array {
if (!$this->data) {
throw new Exception('Server not loaded');
}
$pdo = DB::conn();
$stmt = $pdo->prepare('
SELECT b.*, u.name as created_by_name, u.email as created_by_email
FROM server_backups b
LEFT JOIN users u ON b.created_by = u.id
WHERE b.server_id = ?
ORDER BY b.created_at DESC
');
$stmt->execute([$this->serverId]);
return $stmt->fetchAll();
}
/**
* Restore server from backup
* Note: This only restores client configurations to database
* Server must already be deployed
*
* @param int $backupId Backup ID
* @return array Restoration results
*/
public function restoreBackup(int $backupId): array {
if (!$this->data) {
throw new Exception('Server not loaded');
}
if ($this->data['status'] !== 'active') {
throw new Exception('Server must be active to restore backup');
}
$pdo = DB::conn();
// Get backup record
$stmt = $pdo->prepare('SELECT * FROM server_backups WHERE id = ? AND server_id = ?');
$stmt->execute([$backupId, $this->serverId]);
$backup = $stmt->fetch();
if (!$backup) {
throw new Exception('Backup not found');
}
if (!file_exists($backup['backup_path'])) {
throw new Exception('Backup file not found');
}
// Read backup data
$backupData = json_decode(file_get_contents($backup['backup_path']), true);
if (!$backupData || !isset($backupData['clients'])) {
throw new Exception('Invalid backup format');
}
$restored = 0;
$failed = 0;
$errors = [];
foreach ($backupData['clients'] as $clientData) {
try {
// Check if client already exists by IP
$stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND client_ip = ?');
$stmt->execute([$this->serverId, $clientData['client_ip']]);
$existing = $stmt->fetch();
if ($existing) {
$errors[] = "Client {$clientData['name']} already exists";
$failed++;
continue;
}
// Insert client
$stmt = $pdo->prepare('
INSERT INTO vpn_clients
(server_id, user_id, name, client_ip, public_key, private_key, preshared_key,
config, status, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([
$this->serverId,
$this->data['user_id'],
$clientData['name'],
$clientData['client_ip'],
$clientData['public_key'],
$clientData['private_key'],
$clientData['preshared_key'],
$clientData['config'],
'disabled', // Restore as disabled for safety
$clientData['expires_at']
]);
// Add client to server container
VpnClient::addClientToServer($this->data, $clientData['public_key'], $clientData['client_ip']);
$restored++;
} catch (Exception $e) {
$failed++;
$errors[] = "Failed to restore {$clientData['name']}: " . $e->getMessage();
}
}
return [
'success' => true, // Always success if process completed
'restored' => $restored,
'failed' => $failed,
'total' => count($backupData['clients']),
'errors' => $errors,
'message' => $restored > 0 ? "Restored $restored clients" : "No clients restored"
];
}
/**
* Delete backup
*
* @param int $backupId Backup ID
* @return bool Success
*/
public static function deleteBackup(int $backupId): bool {
$pdo = DB::conn();
// Get backup path
$stmt = $pdo->prepare('SELECT backup_path FROM server_backups WHERE id = ?');
$stmt->execute([$backupId]);
$backup = $stmt->fetch();
if (!$backup) {
return false;
}
// Delete file
if (file_exists($backup['backup_path'])) {
unlink($backup['backup_path']);
}
// Delete record
$stmt = $pdo->prepare('DELETE FROM server_backups WHERE id = ?');
return $stmt->execute([$backupId]);
}
/**
* Get backup by ID
*
* @param int $backupId Backup ID
* @return array|null Backup data
*/
public static function getBackup(int $backupId): ?array {
$pdo = DB::conn();
$stmt = $pdo->prepare('SELECT * FROM server_backups WHERE id = ?');
$stmt->execute([$backupId]);
return $stmt->fetch() ?: null;
}
} }
@@ -60,11 +60,13 @@ CREATE TABLE IF NOT EXISTS vpn_clients (
last_handshake TIMESTAMP NULL COMMENT 'Last successful WireGuard handshake', last_handshake TIMESTAMP NULL COMMENT 'Last successful WireGuard handshake',
last_sync_at TIMESTAMP NULL COMMENT 'Last time stats were synced from server', last_sync_at TIMESTAMP NULL COMMENT 'Last time stats were synced from server',
status ENUM('active', 'disabled') DEFAULT 'active', status ENUM('active', 'disabled') DEFAULT 'active',
expires_at TIMESTAMP NULL COMMENT 'Client expiration date (NULL = never expires)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_server_id (server_id), INDEX idx_server_id (server_id),
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_status (status), INDEX idx_status (status),
INDEX idx_expires_at (expires_at),
INDEX idx_last_handshake (last_handshake), INDEX idx_last_handshake (last_handshake),
UNIQUE KEY unique_server_client_ip (server_id, client_ip), UNIQUE KEY unique_server_client_ip (server_id, client_ip),
FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE, FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE,
@@ -137,6 +139,26 @@ CREATE TABLE IF NOT EXISTS api_keys (
INDEX idx_active (is_active) INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Server Backups table
CREATE TABLE IF NOT EXISTS server_backups (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
server_id INT UNSIGNED NOT NULL,
backup_name VARCHAR(255) NOT NULL COMMENT 'Backup file name',
backup_path VARCHAR(500) NOT NULL COMMENT 'Path to backup file',
backup_size BIGINT UNSIGNED DEFAULT 0 COMMENT 'Backup file size in bytes',
clients_count INT UNSIGNED DEFAULT 0 COMMENT 'Number of clients in backup',
backup_type ENUM('manual', 'automatic') DEFAULT 'manual',
status ENUM('creating', 'completed', 'failed') DEFAULT 'creating',
error_message TEXT NULL COMMENT 'Error message if backup failed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INT UNSIGNED NULL COMMENT 'User who created the backup',
INDEX idx_server_id (server_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default admin user -- Insert default admin user
INSERT IGNORE INTO users (email, password_hash, name, role, status) INSERT IGNORE INTO users (email, password_hash, name, role, status)
VALUES ('admin@amnez.ia', '$2y$10$SKEI6ogiWr2gsSG/nELLp.JcfpGhxsDLAAI7gdtTOI3ELz4zJzzPG', 'Administrator', 'admin', 'active'); VALUES ('admin@amnez.ia', '$2y$10$SKEI6ogiWr2gsSG/nELLp.JcfpGhxsDLAAI7gdtTOI3ELz4zJzzPG', 'Administrator', 'admin', 'active');
@@ -160,8 +182,15 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('en', 'auth.register', 'Register'), ('en', 'auth.register', 'Register'),
('en', 'clients.actions', 'Actions'), ('en', 'clients.actions', 'Actions'),
('en', 'clients.add', 'Add Client'), ('en', 'clients.add', 'Add Client'),
('en', 'clients.create', 'Create Client'),
('en', 'clients.delete', 'Delete'), ('en', 'clients.delete', 'Delete'),
('en', 'clients.delete_confirm', 'Delete this client permanently?'),
('en', 'clients.download_config', 'Download Config'), ('en', 'clients.download_config', 'Download Config'),
('en', 'clients.expiration', 'Expiration'),
('en', 'clients.expired', 'Expired'),
('en', 'clients.never', 'Never'),
('en', 'clients.never_expires', 'Never expires'),
('en', 'clients.no_clients', 'No clients yet'),
('en', 'clients.ip', 'IP Address'), ('en', 'clients.ip', 'IP Address'),
('en', 'clients.last_handshake', 'Last Handshake'), ('en', 'clients.last_handshake', 'Last Handshake'),
('en', 'clients.name', 'Client Name'), ('en', 'clients.name', 'Client Name'),
@@ -169,12 +198,25 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('en', 'clients.received', 'Received'), ('en', 'clients.received', 'Received'),
('en', 'clients.restore', 'Restore'), ('en', 'clients.restore', 'Restore'),
('en', 'clients.revoke', 'Revoke'), ('en', 'clients.revoke', 'Revoke'),
('en', 'clients.revoke_confirm', 'Revoke access for this client?'),
('en', 'clients.sent', 'Sent'), ('en', 'clients.sent', 'Sent'),
('en', 'clients.server', 'Server'), ('en', 'clients.server', 'Server'),
('en', 'clients.status', 'Status'), ('en', 'clients.status', 'Status'),
('en', 'clients.sync_stats', 'Sync Stats'), ('en', 'clients.sync_stats', 'Sync Stats'),
('en', 'clients.title', 'Clients'), ('en', 'clients.title', 'Clients'),
('en', 'clients.traffic', 'Traffic'), ('en', 'clients.traffic', 'Traffic'),
('en', 'backups.title', 'Server Backups'),
('en', 'backups.create', 'Create Backup'),
('en', 'backups.restore', 'Restore'),
('en', 'backups.no_backups', 'No backups yet'),
('en', 'backups.create_confirm', 'Create backup of all clients on this server?'),
('en', 'backups.restore_confirm', 'Restore clients from this backup? Existing clients will not be affected.'),
('en', 'backups.delete_confirm', 'Delete this backup permanently?'),
('en', 'backups.created_success', 'Backup created successfully'),
('en', 'backups.restored_success', 'Restored'),
('en', 'backups.deleted_success', 'Backup deleted successfully'),
('en', 'backups.login_required', 'Please login via API to manage backups'),
('en', 'common.days', 'days'),
('en', 'dashboard.active_clients', 'Active Clients'), ('en', 'dashboard.active_clients', 'Active Clients'),
('en', 'dashboard.add_first_server', 'Add First Server'), ('en', 'dashboard.add_first_server', 'Add First Server'),
('en', 'dashboard.get_started', 'Get started by adding your first VPN server'), ('en', 'dashboard.get_started', 'Get started by adding your first VPN server'),
@@ -188,6 +230,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('en', 'dashboard.welcome', 'Welcome to Amnezia VPN Management Panel'), ('en', 'dashboard.welcome', 'Welcome to Amnezia VPN Management Panel'),
('en', 'form.cancel', 'Cancel'), ('en', 'form.cancel', 'Cancel'),
('en', 'form.close', 'Close'), ('en', 'form.close', 'Close'),
('en', 'form.create', 'Create'),
('en', 'form.loading', 'Loading...'), ('en', 'form.loading', 'Loading...'),
('en', 'form.processing', 'Processing...'), ('en', 'form.processing', 'Processing...'),
('en', 'form.save', 'Save'), ('en', 'form.save', 'Save'),
@@ -221,7 +264,10 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('en', 'settings.api_keys', 'API Keys'), ('en', 'settings.api_keys', 'API Keys'),
('en', 'settings.api_keys_desc', 'Configure API keys for external services'), ('en', 'settings.api_keys_desc', 'Configure API keys for external services'),
('en', 'settings.auto_translate', 'Auto-translate'), ('en', 'settings.auto_translate', 'Auto-translate'),
('en', 'settings.change_password', 'Change Password'),
('en', 'settings.confirm_password', 'Confirm Password'),
('en', 'settings.confirm_translate', 'Start automatic translation? This may take a few minutes.'), ('en', 'settings.confirm_translate', 'Start automatic translation? This may take a few minutes.'),
('en', 'settings.current_password', 'Current Password'),
('en', 'settings.description', 'Manage panel configuration and API integrations'), ('en', 'settings.description', 'Manage panel configuration and API integrations'),
('en', 'settings.error_empty_key', 'API key cannot be empty'), ('en', 'settings.error_empty_key', 'API key cannot be empty'),
('en', 'settings.error_invalid_key', 'Invalid API key format'), ('en', 'settings.error_invalid_key', 'Invalid API key format'),
@@ -231,12 +277,28 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('en', 'settings.key_saved', 'API key saved successfully'), ('en', 'settings.key_saved', 'API key saved successfully'),
('en', 'settings.keys', 'keys'), ('en', 'settings.keys', 'keys'),
('en', 'settings.language', 'Language'), ('en', 'settings.language', 'Language'),
('en', 'settings.min_6_chars', 'Minimum 6 characters'),
('en', 'settings.new_password', 'New Password'),
('en', 'settings.profile', 'Profile'),
('en', 'settings.progress', 'Progress'), ('en', 'settings.progress', 'Progress'),
('en', 'settings.translations', 'Translations'),
('en', 'settings.translation_complete', 'Translation completed'), ('en', 'settings.translation_complete', 'Translation completed'),
('en', 'settings.translation_status', 'Translation Status'), ('en', 'settings.translation_status', 'Translation Status'),
('en', 'settings.users', 'Users'),
('en', 'status.active', 'Active'), ('en', 'status.active', 'Active'),
('en', 'status.deploying', 'Deploying'), ('en', 'status.deploying', 'Deploying'),
('en', 'status.disabled', 'Disabled'), ('en', 'status.disabled', 'Disabled'),
('en', 'status.error', 'Error'), ('en', 'status.error', 'Error'),
('en', 'status.inactive', 'Inactive') ('en', 'status.inactive', 'Inactive'),
('en', 'users.add_user', 'Add User'),
('en', 'users.all_users', 'All Users'),
('en', 'users.administrator', 'Administrator'),
('en', 'users.created', 'Created'),
('en', 'users.delete_confirm', 'Delete {0}?'),
('en', 'users.role', 'Role'),
('en', 'users.role_admin', 'Admin'),
('en', 'users.role_user', 'User'),
('en', 'settings.api_key_configured', 'API Key Configured'),
('en', 'settings.no_api_key', 'No API key configured. Auto-translation will not work.'),
('en', 'settings.skip_validation', 'Skip validation (save without testing)')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+127
View File
@@ -0,0 +1,127 @@
-- Spanish translations
-- This migration adds Spanish language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('es', 'auth.email', 'Correo electrónico'),
('es', 'auth.login', 'Iniciar sesión'),
('es', 'auth.name', 'Nombre'),
('es', 'auth.password', 'Contraseña'),
('es', 'auth.register', 'Registrarse'),
('es', 'backups.create', 'Crear copia de seguridad'),
('es', 'backups.create_confirm', '¿Crear copia de seguridad de todos los clientes en este servidor?'),
('es', 'backups.created_success', 'Copia de seguridad creada exitosamente'),
('es', 'backups.delete_confirm', '¿Eliminar esta copia de seguridad permanentemente?'),
('es', 'backups.deleted_success', 'Copia de seguridad eliminada exitosamente'),
('es', 'backups.login_required', 'Por favor inicie sesión vía API para gestionar copias de seguridad'),
('es', 'backups.no_backups', 'Aún no hay copias de seguridad'),
('es', 'backups.restore', 'Restaurar'),
('es', 'backups.restore_confirm', '¿Restaurar clientes desde esta copia de seguridad? Los clientes existentes no se verán afectados.'),
('es', 'backups.restored_success', 'Restaurado'),
('es', 'backups.title', 'Copias de seguridad del servidor'),
('es', 'clients.actions', 'Acciones'),
('es', 'clients.add', 'Agregar cliente'),
('es', 'clients.create', 'Crear cliente'),
('es', 'clients.delete', 'Eliminar'),
('es', 'clients.download_config', 'Descargar configuración'),
('es', 'clients.expiration', 'Vencimiento'),
('es', 'clients.expired', 'Vencido'),
('es', 'clients.ip', 'Dirección IP'),
('es', 'clients.last_handshake', 'Último contacto'),
('es', 'clients.name', 'Nombre del cliente'),
('es', 'clients.never_expires', 'Nunca vence'),
('es', 'clients.qr_code', 'Código QR'),
('es', 'clients.received', 'Recibido'),
('es', 'clients.restore', 'Restaurar'),
('es', 'clients.revoke', 'Revocar'),
('es', 'clients.sent', 'Enviado'),
('es', 'clients.server', 'Servidor'),
('es', 'clients.status', 'Estado'),
('es', 'clients.sync_stats', 'Sincronizar estadísticas'),
('es', 'clients.title', 'Clientes'),
('es', 'clients.traffic', 'Tráfico'),
('es', 'common.days', 'días'),
('es', 'dashboard.active_clients', 'Clientes activos'),
('es', 'dashboard.add_first_server', 'Agregar primer servidor'),
('es', 'dashboard.get_started', 'Comience agregando su primer servidor VPN'),
('es', 'dashboard.no_servers', 'Aún no hay servidores'),
('es', 'dashboard.quick_actions', 'Acciones rápidas'),
('es', 'dashboard.recent_servers', 'Servidores recientes'),
('es', 'dashboard.title', 'Panel de control'),
('es', 'dashboard.total_clients', 'Total de clientes'),
('es', 'dashboard.total_servers', 'Total de servidores'),
('es', 'dashboard.total_traffic', 'Tráfico total'),
('es', 'dashboard.welcome', 'Bienvenido al Panel de Gestión de Amnezia VPN'),
('es', 'form.cancel', 'Cancelar'),
('es', 'form.close', 'Cerrar'),
('es', 'form.create', 'Crear'),
('es', 'form.loading', 'Cargando...'),
('es', 'form.processing', 'Procesando...'),
('es', 'form.save', 'Guardar'),
('es', 'form.submit', 'Enviar'),
('es', 'form.update', 'Actualizar'),
('es', 'menu.clients', 'Clientes'),
('es', 'menu.dashboard', 'Panel de control'),
('es', 'menu.logout', 'Cerrar sesión'),
('es', 'menu.servers', 'Servidores'),
('es', 'menu.settings', 'Configuración'),
('es', 'menu.users', 'Usuarios'),
('es', 'message.confirm', '¿Está seguro?'),
('es', 'message.deleted', 'Eliminado exitosamente'),
('es', 'message.deployed', 'Implementado exitosamente'),
('es', 'message.error', 'Ha ocurrido un error'),
('es', 'message.saved', 'Guardado exitosamente'),
('es', 'message.success', 'Operación completada exitosamente'),
('es', 'servers.actions', 'Acciones'),
('es', 'servers.add', 'Agregar servidor'),
('es', 'servers.clients', 'Clientes'),
('es', 'servers.delete', 'Eliminar'),
('es', 'servers.deploy', 'Implementar'),
('es', 'servers.edit', 'Editar'),
('es', 'servers.host', 'Host'),
('es', 'servers.name', 'Nombre'),
('es', 'servers.port', 'Puerto'),
('es', 'servers.status', 'Estado'),
('es', 'servers.title', 'Servidores'),
('es', 'servers.view', 'Ver'),
('es', 'settings.actions', 'Acciones'),
('es', 'settings.api_key_configured', 'Clave API configurada'),
('es', 'settings.api_keys', 'Claves API'),
('es', 'settings.api_keys_desc', 'Configurar claves API para servicios externos'),
('es', 'settings.auto_translate', 'Auto-traducir'),
('es', 'settings.change_password', 'Cambiar contraseña'),
('es', 'settings.confirm_password', 'Confirmar contraseña'),
('es', 'settings.confirm_translate', '¿Iniciar traducción automática? Esto puede tomar unos minutos.'),
('es', 'settings.current_password', 'Contraseña actual'),
('es', 'settings.description', 'Gestionar configuración del panel e integraciones API'),
('es', 'settings.error_empty_key', 'La clave API no puede estar vacía'),
('es', 'settings.error_invalid_key', 'Formato de clave API inválido'),
('es', 'settings.error_key_test', 'Prueba de clave API fallida'),
('es', 'settings.for_translation', 'para auto-traducción'),
('es', 'settings.get_key_at', 'Obtenga su clave API en'),
('es', 'settings.key_saved', 'Clave API guardada exitosamente'),
('es', 'settings.keys', 'claves'),
('es', 'settings.language', 'Idioma'),
('es', 'settings.min_6_chars', 'Mínimo 6 caracteres'),
('es', 'settings.new_password', 'Nueva contraseña'),
('es', 'settings.no_api_key', 'No hay clave API configurada. La auto-traducción no funcionará.'),
('es', 'settings.profile', 'Perfil'),
('es', 'settings.progress', 'Progreso'),
('es', 'settings.skip_validation', 'Omitir validación (guardar sin probar)'),
('es', 'settings.translation_complete', 'Traducción completada'),
('es', 'settings.translation_status', 'Estado de traducción'),
('es', 'settings.translations', 'Traducciones'),
('es', 'settings.users', 'Usuarios'),
('es', 'status.active', 'Activo'),
('es', 'status.deploying', 'Implementando'),
('es', 'status.disabled', 'Deshabilitado'),
('es', 'status.error', 'Error'),
('es', 'status.inactive', 'Inactivo'),
('es', 'users.add_user', 'Agregar usuario'),
('es', 'users.administrator', 'Administrador'),
('es', 'users.all_users', 'Todos los usuarios'),
('es', 'users.created', 'Creado'),
('es', 'users.delete_confirm', '¿Eliminar {0}?'),
('es', 'users.role', 'Rol'),
('es', 'users.role_admin', 'Administrador'),
('es', 'users.role_user', 'Usuario')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+131
View File
@@ -0,0 +1,131 @@
-- Russian translations
-- This migration adds Russian language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('ru', 'auth.email', 'Email'),
('ru', 'auth.login', 'Вход'),
('ru', 'auth.name', 'Имя'),
('ru', 'auth.password', 'Пароль'),
('ru', 'auth.register', 'Регистрация'),
('ru', 'backups.create', 'Создать резервную копию'),
('ru', 'backups.create_confirm', 'Создать резервную копию всех клиентов на этом сервере?'),
('ru', 'backups.created_success', 'Резервная копия успешно создана'),
('ru', 'backups.delete_confirm', 'Удалить эту резервную копию навсегда?'),
('ru', 'backups.deleted_success', 'Резервная копия успешно удалена'),
('ru', 'backups.login_required', 'Пожалуйста, войдите через API для управления резервными копиями'),
('ru', 'backups.no_backups', 'Пока нет резервных копий'),
('ru', 'backups.restore', 'Восстановить'),
('ru', 'backups.restore_confirm', 'Восстановить клиентов из этой резервной копии? Существующие клиенты не будут затронуты.'),
('ru', 'backups.restored_success', 'Восстановлено'),
('ru', 'backups.title', 'Резервные копии сервера'),
('ru', 'clients.actions', 'Действия'),
('ru', 'clients.add', 'Добавить клиента'),
('ru', 'clients.create', 'Создать клиента'),
('ru', 'clients.delete', 'Удалить'),
('ru', 'clients.delete_confirm', 'Удалить этого клиента навсегда?'),
('ru', 'clients.download_config', 'Скачать конфигурацию'),
('ru', 'clients.expiration', 'Срок действия'),
('ru', 'clients.expired', 'Истек'),
('ru', 'clients.ip', 'IP-адрес'),
('ru', 'clients.last_handshake', 'Последнее соединение'),
('ru', 'clients.name', 'Имя клиента'),
('ru', 'clients.never', 'Никогда'),
('ru', 'clients.never_expires', 'Бессрочно'),
('ru', 'clients.no_clients', 'Пока нет клиентов'),
('ru', 'clients.qr_code', 'QR-код'),
('ru', 'clients.received', 'Получено'),
('ru', 'clients.restore', 'Восстановить'),
('ru', 'clients.revoke', 'Отозвать'),
('ru', 'clients.revoke_confirm', 'Отозвать доступ для этого клиента?'),
('ru', 'clients.sent', 'Отправлено'),
('ru', 'clients.server', 'Сервер'),
('ru', 'clients.status', 'Статус'),
('ru', 'clients.sync_stats', 'Синхронизировать статистику'),
('ru', 'clients.title', 'Клиенты'),
('ru', 'clients.traffic', 'Трафик'),
('ru', 'common.days', 'дней'),
('ru', 'dashboard.active_clients', 'Активные клиенты'),
('ru', 'dashboard.add_first_server', 'Добавить первый сервер'),
('ru', 'dashboard.get_started', 'Начните с добавления вашего первого VPN-сервера'),
('ru', 'dashboard.no_servers', 'Пока нет серверов'),
('ru', 'dashboard.quick_actions', 'Быстрые действия'),
('ru', 'dashboard.recent_servers', 'Недавние серверы'),
('ru', 'dashboard.title', 'Панель управления'),
('ru', 'dashboard.total_clients', 'Всего клиентов'),
('ru', 'dashboard.total_servers', 'Всего серверов'),
('ru', 'dashboard.total_traffic', 'Общий трафик'),
('ru', 'dashboard.welcome', 'Добро пожаловать в панель управления Amnezia VPN'),
('ru', 'form.cancel', 'Отмена'),
('ru', 'form.close', 'Закрыть'),
('ru', 'form.create', 'Создать'),
('ru', 'form.loading', 'Загрузка...'),
('ru', 'form.processing', 'Обработка...'),
('ru', 'form.save', 'Сохранить'),
('ru', 'form.submit', 'Отправить'),
('ru', 'form.update', 'Обновить'),
('ru', 'menu.clients', 'Клиенты'),
('ru', 'menu.dashboard', 'Панель управления'),
('ru', 'menu.logout', 'Выход'),
('ru', 'menu.servers', 'Серверы'),
('ru', 'menu.settings', 'Настройки'),
('ru', 'menu.users', 'Пользователи'),
('ru', 'message.confirm', 'Вы уверены?'),
('ru', 'message.deleted', 'Успешно удалено'),
('ru', 'message.deployed', 'Успешно развернуто'),
('ru', 'message.error', 'Произошла ошибка'),
('ru', 'message.saved', 'Успешно сохранено'),
('ru', 'message.success', 'Операция успешно завершена'),
('ru', 'servers.actions', 'Действия'),
('ru', 'servers.add', 'Добавить сервер'),
('ru', 'servers.clients', 'Клиенты'),
('ru', 'servers.delete', 'Удалить'),
('ru', 'servers.deploy', 'Развернуть'),
('ru', 'servers.edit', 'Редактировать'),
('ru', 'servers.host', 'Хост'),
('ru', 'servers.name', 'Имя'),
('ru', 'servers.port', 'Порт'),
('ru', 'servers.status', 'Статус'),
('ru', 'servers.title', 'Серверы'),
('ru', 'servers.view', 'Просмотр'),
('ru', 'settings.actions', 'Действия'),
('ru', 'settings.api_key_configured', 'API-ключ настроен'),
('ru', 'settings.api_keys', 'API-ключи'),
('ru', 'settings.api_keys_desc', 'Настройка API-ключей для внешних сервисов'),
('ru', 'settings.auto_translate', 'Автоперевод'),
('ru', 'settings.change_password', 'Изменить пароль'),
('ru', 'settings.confirm_password', 'Подтвердите пароль'),
('ru', 'settings.confirm_translate', 'Начать автоматический перевод? Это может занять несколько минут.'),
('ru', 'settings.current_password', 'Текущий пароль'),
('ru', 'settings.description', 'Управление конфигурацией панели и интеграциями API'),
('ru', 'settings.error_empty_key', 'API-ключ не может быть пустым'),
('ru', 'settings.error_invalid_key', 'Неверный формат API-ключа'),
('ru', 'settings.error_key_test', 'Тест API-ключа не удался'),
('ru', 'settings.for_translation', 'для автоперевода'),
('ru', 'settings.get_key_at', 'Получите ваш API-ключ на'),
('ru', 'settings.key_saved', 'API-ключ успешно сохранен'),
('ru', 'settings.keys', 'ключи'),
('ru', 'settings.language', 'Язык'),
('ru', 'settings.min_6_chars', 'Минимум 6 символов'),
('ru', 'settings.new_password', 'Новый пароль'),
('ru', 'settings.no_api_key', 'API-ключ не настроен. Автоперевод не будет работать.'),
('ru', 'settings.profile', 'Профиль'),
('ru', 'settings.progress', 'Прогресс'),
('ru', 'settings.skip_validation', 'Пропустить проверку (сохранить без тестирования)'),
('ru', 'settings.translation_complete', 'Перевод завершен'),
('ru', 'settings.translation_status', 'Статус перевода'),
('ru', 'settings.translations', 'Переводы'),
('ru', 'settings.users', 'Пользователи'),
('ru', 'status.active', 'Активен'),
('ru', 'status.deploying', 'Развертывание'),
('ru', 'status.disabled', 'Отключен'),
('ru', 'status.error', 'Ошибка'),
('ru', 'status.inactive', 'Неактивен'),
('ru', 'users.add_user', 'Добавить пользователя'),
('ru', 'users.administrator', 'Администратор'),
('ru', 'users.all_users', 'Все пользователи'),
('ru', 'users.created', 'Создан'),
('ru', 'users.delete_confirm', 'Удалить {0}?'),
('ru', 'users.role', 'Роль'),
('ru', 'users.role_admin', 'Администратор'),
('ru', 'users.role_user', 'Пользователь')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+131
View File
@@ -0,0 +1,131 @@
-- Spanish translations
-- This migration adds Spanish language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('es', 'auth.email', 'Correo electrónico'),
('es', 'auth.login', 'Iniciar sesión'),
('es', 'auth.name', 'Nombre'),
('es', 'auth.password', 'Contraseña'),
('es', 'auth.register', 'Registrarse'),
('es', 'backups.create', 'Crear copia de seguridad'),
('es', 'backups.create_confirm', '¿Crear copia de seguridad de todos los clientes en este servidor?'),
('es', 'backups.created_success', 'Copia de seguridad creada exitosamente'),
('es', 'backups.delete_confirm', '¿Eliminar esta copia de seguridad permanentemente?'),
('es', 'backups.deleted_success', 'Copia de seguridad eliminada exitosamente'),
('es', 'backups.login_required', 'Por favor inicie sesión vía API para gestionar copias de seguridad'),
('es', 'backups.no_backups', 'Aún no hay copias de seguridad'),
('es', 'backups.restore', 'Restaurar'),
('es', 'backups.restore_confirm', '¿Restaurar clientes desde esta copia de seguridad? Los clientes existentes no se verán afectados.'),
('es', 'backups.restored_success', 'Restaurado'),
('es', 'backups.title', 'Copias de seguridad del servidor'),
('es', 'clients.actions', 'Acciones'),
('es', 'clients.add', 'Agregar cliente'),
('es', 'clients.create', 'Crear cliente'),
('es', 'clients.delete', 'Eliminar'),
('es', 'clients.delete_confirm', '¿Eliminar este cliente permanentemente?'),
('es', 'clients.download_config', 'Descargar configuración'),
('es', 'clients.expiration', 'Vencimiento'),
('es', 'clients.expired', 'Vencido'),
('es', 'clients.ip', 'Dirección IP'),
('es', 'clients.last_handshake', 'Último contacto'),
('es', 'clients.name', 'Nombre del cliente'),
('es', 'clients.never', 'Nunca'),
('es', 'clients.never_expires', 'Nunca vence'),
('es', 'clients.no_clients', 'Aún no hay clientes'),
('es', 'clients.qr_code', 'Código QR'),
('es', 'clients.received', 'Recibido'),
('es', 'clients.restore', 'Restaurar'),
('es', 'clients.revoke', 'Revocar'),
('es', 'clients.revoke_confirm', '¿Revocar acceso para este cliente?'),
('es', 'clients.sent', 'Enviado'),
('es', 'clients.server', 'Servidor'),
('es', 'clients.status', 'Estado'),
('es', 'clients.sync_stats', 'Sincronizar estadísticas'),
('es', 'clients.title', 'Clientes'),
('es', 'clients.traffic', 'Tráfico'),
('es', 'common.days', 'días'),
('es', 'dashboard.active_clients', 'Clientes activos'),
('es', 'dashboard.add_first_server', 'Agregar primer servidor'),
('es', 'dashboard.get_started', 'Comience agregando su primer servidor VPN'),
('es', 'dashboard.no_servers', 'Aún no hay servidores'),
('es', 'dashboard.quick_actions', 'Acciones rápidas'),
('es', 'dashboard.recent_servers', 'Servidores recientes'),
('es', 'dashboard.title', 'Panel de control'),
('es', 'dashboard.total_clients', 'Total de clientes'),
('es', 'dashboard.total_servers', 'Total de servidores'),
('es', 'dashboard.total_traffic', 'Tráfico total'),
('es', 'dashboard.welcome', 'Bienvenido al Panel de Gestión de Amnezia VPN'),
('es', 'form.cancel', 'Cancelar'),
('es', 'form.close', 'Cerrar'),
('es', 'form.create', 'Crear'),
('es', 'form.loading', 'Cargando...'),
('es', 'form.processing', 'Procesando...'),
('es', 'form.save', 'Guardar'),
('es', 'form.submit', 'Enviar'),
('es', 'form.update', 'Actualizar'),
('es', 'menu.clients', 'Clientes'),
('es', 'menu.dashboard', 'Panel de control'),
('es', 'menu.logout', 'Cerrar sesión'),
('es', 'menu.servers', 'Servidores'),
('es', 'menu.settings', 'Configuración'),
('es', 'menu.users', 'Usuarios'),
('es', 'message.confirm', '¿Está seguro?'),
('es', 'message.deleted', 'Eliminado exitosamente'),
('es', 'message.deployed', 'Implementado exitosamente'),
('es', 'message.error', 'Ha ocurrido un error'),
('es', 'message.saved', 'Guardado exitosamente'),
('es', 'message.success', 'Operación completada exitosamente'),
('es', 'servers.actions', 'Acciones'),
('es', 'servers.add', 'Agregar servidor'),
('es', 'servers.clients', 'Clientes'),
('es', 'servers.delete', 'Eliminar'),
('es', 'servers.deploy', 'Implementar'),
('es', 'servers.edit', 'Editar'),
('es', 'servers.host', 'Host'),
('es', 'servers.name', 'Nombre'),
('es', 'servers.port', 'Puerto'),
('es', 'servers.status', 'Estado'),
('es', 'servers.title', 'Servidores'),
('es', 'servers.view', 'Ver'),
('es', 'settings.actions', 'Acciones'),
('es', 'settings.api_key_configured', 'Clave API configurada'),
('es', 'settings.api_keys', 'Claves API'),
('es', 'settings.api_keys_desc', 'Configurar claves API para servicios externos'),
('es', 'settings.auto_translate', 'Auto-traducir'),
('es', 'settings.change_password', 'Cambiar contraseña'),
('es', 'settings.confirm_password', 'Confirmar contraseña'),
('es', 'settings.confirm_translate', '¿Iniciar traducción automática? Esto puede tomar unos minutos.'),
('es', 'settings.current_password', 'Contraseña actual'),
('es', 'settings.description', 'Gestionar configuración del panel e integraciones API'),
('es', 'settings.error_empty_key', 'La clave API no puede estar vacía'),
('es', 'settings.error_invalid_key', 'Formato de clave API inválido'),
('es', 'settings.error_key_test', 'Prueba de clave API fallida'),
('es', 'settings.for_translation', 'para auto-traducción'),
('es', 'settings.get_key_at', 'Obtenga su clave API en'),
('es', 'settings.key_saved', 'Clave API guardada exitosamente'),
('es', 'settings.keys', 'claves'),
('es', 'settings.language', 'Idioma'),
('es', 'settings.min_6_chars', 'Mínimo 6 caracteres'),
('es', 'settings.new_password', 'Nueva contraseña'),
('es', 'settings.no_api_key', 'No hay clave API configurada. La auto-traducción no funcionará.'),
('es', 'settings.profile', 'Perfil'),
('es', 'settings.progress', 'Progreso'),
('es', 'settings.skip_validation', 'Omitir validación (guardar sin probar)'),
('es', 'settings.translation_complete', 'Traducción completada'),
('es', 'settings.translation_status', 'Estado de traducción'),
('es', 'settings.translations', 'Traducciones'),
('es', 'settings.users', 'Usuarios'),
('es', 'status.active', 'Activo'),
('es', 'status.deploying', 'Implementando'),
('es', 'status.disabled', 'Deshabilitado'),
('es', 'status.error', 'Error'),
('es', 'status.inactive', 'Inactivo'),
('es', 'users.add_user', 'Agregar usuario'),
('es', 'users.administrator', 'Administrador'),
('es', 'users.all_users', 'Todos los usuarios'),
('es', 'users.created', 'Creado'),
('es', 'users.delete_confirm', '¿Eliminar {0}?'),
('es', 'users.role', 'Rol'),
('es', 'users.role_admin', 'Administrador'),
('es', 'users.role_user', 'Usuario')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+127
View File
@@ -0,0 +1,127 @@
-- German translations
-- This migration adds German language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('de', 'auth.email', 'E-Mail'),
('de', 'auth.login', 'Anmelden'),
('de', 'auth.name', 'Name'),
('de', 'auth.password', 'Passwort'),
('de', 'auth.register', 'Registrieren'),
('de', 'backups.create', 'Backup erstellen'),
('de', 'backups.create_confirm', 'Backup aller Clients auf diesem Server erstellen?'),
('de', 'backups.created_success', 'Backup erfolgreich erstellt'),
('de', 'backups.delete_confirm', 'Dieses Backup endgültig löschen?'),
('de', 'backups.deleted_success', 'Backup erfolgreich gelöscht'),
('de', 'backups.login_required', 'Bitte melden Sie sich über die API an, um Backups zu verwalten'),
('de', 'backups.no_backups', 'Noch keine Backups'),
('de', 'backups.restore', 'Wiederherstellen'),
('de', 'backups.restore_confirm', 'Clients aus diesem Backup wiederherstellen? Bestehende Clients bleiben unberührt.'),
('de', 'backups.restored_success', 'Wiederhergestellt'),
('de', 'backups.title', 'Server-Backups'),
('de', 'clients.actions', 'Aktionen'),
('de', 'clients.add', 'Client hinzufügen'),
('de', 'clients.create', 'Client erstellen'),
('de', 'clients.delete', 'Löschen'),
('de', 'clients.download_config', 'Konfiguration herunterladen'),
('de', 'clients.expiration', 'Ablaufdatum'),
('de', 'clients.expired', 'Abgelaufen'),
('de', 'clients.ip', 'IP-Adresse'),
('de', 'clients.last_handshake', 'Letzter Handshake'),
('de', 'clients.name', 'Client-Name'),
('de', 'clients.never_expires', 'Läuft nie ab'),
('de', 'clients.qr_code', 'QR-Code'),
('de', 'clients.received', 'Empfangen'),
('de', 'clients.restore', 'Wiederherstellen'),
('de', 'clients.revoke', 'Widerrufen'),
('de', 'clients.sent', 'Gesendet'),
('de', 'clients.server', 'Server'),
('de', 'clients.status', 'Status'),
('de', 'clients.sync_stats', 'Statistiken synchronisieren'),
('de', 'clients.title', 'Clients'),
('de', 'clients.traffic', 'Datenverkehr'),
('de', 'common.days', 'Tage'),
('de', 'dashboard.active_clients', 'Aktive Clients'),
('de', 'dashboard.add_first_server', 'Ersten Server hinzufügen'),
('de', 'dashboard.get_started', 'Beginnen Sie mit dem Hinzufügen Ihres ersten VPN-Servers'),
('de', 'dashboard.no_servers', 'Noch keine Server'),
('de', 'dashboard.quick_actions', 'Schnellaktionen'),
('de', 'dashboard.recent_servers', 'Aktuelle Server'),
('de', 'dashboard.title', 'Dashboard'),
('de', 'dashboard.total_clients', 'Gesamtzahl Clients'),
('de', 'dashboard.total_servers', 'Gesamtzahl Server'),
('de', 'dashboard.total_traffic', 'Gesamter Datenverkehr'),
('de', 'dashboard.welcome', 'Willkommen im Amnezia VPN Verwaltungspanel'),
('de', 'form.cancel', 'Abbrechen'),
('de', 'form.close', 'Schließen'),
('de', 'form.create', 'Erstellen'),
('de', 'form.loading', 'Lädt...'),
('de', 'form.processing', 'Verarbeitung...'),
('de', 'form.save', 'Speichern'),
('de', 'form.submit', 'Absenden'),
('de', 'form.update', 'Aktualisieren'),
('de', 'menu.clients', 'Clients'),
('de', 'menu.dashboard', 'Dashboard'),
('de', 'menu.logout', 'Abmelden'),
('de', 'menu.servers', 'Server'),
('de', 'menu.settings', 'Einstellungen'),
('de', 'menu.users', 'Benutzer'),
('de', 'message.confirm', 'Sind Sie sicher?'),
('de', 'message.deleted', 'Erfolgreich gelöscht'),
('de', 'message.deployed', 'Erfolgreich bereitgestellt'),
('de', 'message.error', 'Ein Fehler ist aufgetreten'),
('de', 'message.saved', 'Erfolgreich gespeichert'),
('de', 'message.success', 'Vorgang erfolgreich abgeschlossen'),
('de', 'servers.actions', 'Aktionen'),
('de', 'servers.add', 'Server hinzufügen'),
('de', 'servers.clients', 'Clients'),
('de', 'servers.delete', 'Löschen'),
('de', 'servers.deploy', 'Bereitstellen'),
('de', 'servers.edit', 'Bearbeiten'),
('de', 'servers.host', 'Host'),
('de', 'servers.name', 'Name'),
('de', 'servers.port', 'Port'),
('de', 'servers.status', 'Status'),
('de', 'servers.title', 'Server'),
('de', 'servers.view', 'Ansehen'),
('de', 'settings.actions', 'Aktionen'),
('de', 'settings.api_key_configured', 'API-Schlüssel konfiguriert'),
('de', 'settings.api_keys', 'API-Schlüssel'),
('de', 'settings.api_keys_desc', 'API-Schlüssel für externe Dienste konfigurieren'),
('de', 'settings.auto_translate', 'Automatische Übersetzung'),
('de', 'settings.change_password', 'Passwort ändern'),
('de', 'settings.confirm_password', 'Passwort bestätigen'),
('de', 'settings.confirm_translate', 'Automatische Übersetzung starten? Dies kann einige Minuten dauern.'),
('de', 'settings.current_password', 'Aktuelles Passwort'),
('de', 'settings.description', 'Panel-Konfiguration und API-Integrationen verwalten'),
('de', 'settings.error_empty_key', 'API-Schlüssel darf nicht leer sein'),
('de', 'settings.error_invalid_key', 'Ungültiges API-Schlüssel-Format'),
('de', 'settings.error_key_test', 'API-Schlüssel-Test fehlgeschlagen'),
('de', 'settings.for_translation', 'für automatische Übersetzung'),
('de', 'settings.get_key_at', 'Holen Sie sich Ihren API-Schlüssel bei'),
('de', 'settings.key_saved', 'API-Schlüssel erfolgreich gespeichert'),
('de', 'settings.keys', 'Schlüssel'),
('de', 'settings.language', 'Sprache'),
('de', 'settings.min_6_chars', 'Mindestens 6 Zeichen'),
('de', 'settings.new_password', 'Neues Passwort'),
('de', 'settings.no_api_key', 'Kein API-Schlüssel konfiguriert. Automatische Übersetzung wird nicht funktionieren.'),
('de', 'settings.profile', 'Profil'),
('de', 'settings.progress', 'Fortschritt'),
('de', 'settings.skip_validation', 'Validierung überspringen (ohne Test speichern)'),
('de', 'settings.translation_complete', 'Übersetzung abgeschlossen'),
('de', 'settings.translation_status', 'Übersetzungsstatus'),
('de', 'settings.translations', 'Übersetzungen'),
('de', 'settings.users', 'Benutzer'),
('de', 'status.active', 'Aktiv'),
('de', 'status.deploying', 'Wird bereitgestellt'),
('de', 'status.disabled', 'Deaktiviert'),
('de', 'status.error', 'Fehler'),
('de', 'status.inactive', 'Inaktiv'),
('de', 'users.add_user', 'Benutzer hinzufügen'),
('de', 'users.administrator', 'Administrator'),
('de', 'users.all_users', 'Alle Benutzer'),
('de', 'users.created', 'Erstellt'),
('de', 'users.delete_confirm', '{0} löschen?'),
('de', 'users.role', 'Rolle'),
('de', 'users.role_admin', 'Admin'),
('de', 'users.role_user', 'Benutzer')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+131
View File
@@ -0,0 +1,131 @@
-- German translations
-- This migration adds German language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('de', 'auth.email', 'E-Mail'),
('de', 'auth.login', 'Anmelden'),
('de', 'auth.name', 'Name'),
('de', 'auth.password', 'Passwort'),
('de', 'auth.register', 'Registrieren'),
('de', 'backups.create', 'Backup erstellen'),
('de', 'backups.create_confirm', 'Backup aller Clients auf diesem Server erstellen?'),
('de', 'backups.created_success', 'Backup erfolgreich erstellt'),
('de', 'backups.delete_confirm', 'Dieses Backup endgültig löschen?'),
('de', 'backups.deleted_success', 'Backup erfolgreich gelöscht'),
('de', 'backups.login_required', 'Bitte melden Sie sich über die API an, um Backups zu verwalten'),
('de', 'backups.no_backups', 'Noch keine Backups'),
('de', 'backups.restore', 'Wiederherstellen'),
('de', 'backups.restore_confirm', 'Clients aus diesem Backup wiederherstellen? Bestehende Clients bleiben unberührt.'),
('de', 'backups.restored_success', 'Wiederhergestellt'),
('de', 'backups.title', 'Server-Backups'),
('de', 'clients.actions', 'Aktionen'),
('de', 'clients.add', 'Client hinzufügen'),
('de', 'clients.create', 'Client erstellen'),
('de', 'clients.delete', 'Löschen'),
('de', 'clients.delete_confirm', 'Diesen Client dauerhaft löschen?'),
('de', 'clients.download_config', 'Konfiguration herunterladen'),
('de', 'clients.expiration', 'Ablaufdatum'),
('de', 'clients.expired', 'Abgelaufen'),
('de', 'clients.ip', 'IP-Adresse'),
('de', 'clients.last_handshake', 'Letzter Handshake'),
('de', 'clients.name', 'Client-Name'),
('de', 'clients.never', 'Niemals'),
('de', 'clients.never_expires', 'Läuft nie ab'),
('de', 'clients.no_clients', 'Noch keine Kunden'),
('de', 'clients.qr_code', 'QR-Code'),
('de', 'clients.received', 'Empfangen'),
('de', 'clients.restore', 'Wiederherstellen'),
('de', 'clients.revoke', 'Widerrufen'),
('de', 'clients.revoke_confirm', 'Zugriff für diesen Client widerrufen?'),
('de', 'clients.sent', 'Gesendet'),
('de', 'clients.server', 'Server'),
('de', 'clients.status', 'Status'),
('de', 'clients.sync_stats', 'Statistiken synchronisieren'),
('de', 'clients.title', 'Clients'),
('de', 'clients.traffic', 'Datenverkehr'),
('de', 'common.days', 'Tage'),
('de', 'dashboard.active_clients', 'Aktive Clients'),
('de', 'dashboard.add_first_server', 'Ersten Server hinzufügen'),
('de', 'dashboard.get_started', 'Beginnen Sie mit dem Hinzufügen Ihres ersten VPN-Servers'),
('de', 'dashboard.no_servers', 'Noch keine Server'),
('de', 'dashboard.quick_actions', 'Schnellaktionen'),
('de', 'dashboard.recent_servers', 'Aktuelle Server'),
('de', 'dashboard.title', 'Dashboard'),
('de', 'dashboard.total_clients', 'Gesamtzahl Clients'),
('de', 'dashboard.total_servers', 'Gesamtzahl Server'),
('de', 'dashboard.total_traffic', 'Gesamter Datenverkehr'),
('de', 'dashboard.welcome', 'Willkommen im Amnezia VPN Verwaltungspanel'),
('de', 'form.cancel', 'Abbrechen'),
('de', 'form.close', 'Schließen'),
('de', 'form.create', 'Erstellen'),
('de', 'form.loading', 'Lädt...'),
('de', 'form.processing', 'Verarbeitung...'),
('de', 'form.save', 'Speichern'),
('de', 'form.submit', 'Absenden'),
('de', 'form.update', 'Aktualisieren'),
('de', 'menu.clients', 'Clients'),
('de', 'menu.dashboard', 'Dashboard'),
('de', 'menu.logout', 'Abmelden'),
('de', 'menu.servers', 'Server'),
('de', 'menu.settings', 'Einstellungen'),
('de', 'menu.users', 'Benutzer'),
('de', 'message.confirm', 'Sind Sie sicher?'),
('de', 'message.deleted', 'Erfolgreich gelöscht'),
('de', 'message.deployed', 'Erfolgreich bereitgestellt'),
('de', 'message.error', 'Ein Fehler ist aufgetreten'),
('de', 'message.saved', 'Erfolgreich gespeichert'),
('de', 'message.success', 'Vorgang erfolgreich abgeschlossen'),
('de', 'servers.actions', 'Aktionen'),
('de', 'servers.add', 'Server hinzufügen'),
('de', 'servers.clients', 'Clients'),
('de', 'servers.delete', 'Löschen'),
('de', 'servers.deploy', 'Bereitstellen'),
('de', 'servers.edit', 'Bearbeiten'),
('de', 'servers.host', 'Host'),
('de', 'servers.name', 'Name'),
('de', 'servers.port', 'Port'),
('de', 'servers.status', 'Status'),
('de', 'servers.title', 'Server'),
('de', 'servers.view', 'Ansehen'),
('de', 'settings.actions', 'Aktionen'),
('de', 'settings.api_key_configured', 'API-Schlüssel konfiguriert'),
('de', 'settings.api_keys', 'API-Schlüssel'),
('de', 'settings.api_keys_desc', 'API-Schlüssel für externe Dienste konfigurieren'),
('de', 'settings.auto_translate', 'Automatische Übersetzung'),
('de', 'settings.change_password', 'Passwort ändern'),
('de', 'settings.confirm_password', 'Passwort bestätigen'),
('de', 'settings.confirm_translate', 'Automatische Übersetzung starten? Dies kann einige Minuten dauern.'),
('de', 'settings.current_password', 'Aktuelles Passwort'),
('de', 'settings.description', 'Panel-Konfiguration und API-Integrationen verwalten'),
('de', 'settings.error_empty_key', 'API-Schlüssel darf nicht leer sein'),
('de', 'settings.error_invalid_key', 'Ungültiges API-Schlüssel-Format'),
('de', 'settings.error_key_test', 'API-Schlüssel-Test fehlgeschlagen'),
('de', 'settings.for_translation', 'für automatische Übersetzung'),
('de', 'settings.get_key_at', 'Holen Sie sich Ihren API-Schlüssel bei'),
('de', 'settings.key_saved', 'API-Schlüssel erfolgreich gespeichert'),
('de', 'settings.keys', 'Schlüssel'),
('de', 'settings.language', 'Sprache'),
('de', 'settings.min_6_chars', 'Mindestens 6 Zeichen'),
('de', 'settings.new_password', 'Neues Passwort'),
('de', 'settings.no_api_key', 'Kein API-Schlüssel konfiguriert. Automatische Übersetzung wird nicht funktionieren.'),
('de', 'settings.profile', 'Profil'),
('de', 'settings.progress', 'Fortschritt'),
('de', 'settings.skip_validation', 'Validierung überspringen (ohne Test speichern)'),
('de', 'settings.translation_complete', 'Übersetzung abgeschlossen'),
('de', 'settings.translation_status', 'Übersetzungsstatus'),
('de', 'settings.translations', 'Übersetzungen'),
('de', 'settings.users', 'Benutzer'),
('de', 'status.active', 'Aktiv'),
('de', 'status.deploying', 'Wird bereitgestellt'),
('de', 'status.disabled', 'Deaktiviert'),
('de', 'status.error', 'Fehler'),
('de', 'status.inactive', 'Inaktiv'),
('de', 'users.add_user', 'Benutzer hinzufügen'),
('de', 'users.administrator', 'Administrator'),
('de', 'users.all_users', 'Alle Benutzer'),
('de', 'users.created', 'Erstellt'),
('de', 'users.delete_confirm', '{0} löschen?'),
('de', 'users.role', 'Rolle'),
('de', 'users.role_admin', 'Admin'),
('de', 'users.role_user', 'Benutzer')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+127
View File
@@ -0,0 +1,127 @@
-- Russian translations
-- This migration adds Russian language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('ru', 'auth.email', 'Email'),
('ru', 'auth.login', 'Вход'),
('ru', 'auth.name', 'Имя'),
('ru', 'auth.password', 'Пароль'),
('ru', 'auth.register', 'Регистрация'),
('ru', 'backups.create', 'Создать резервную копию'),
('ru', 'backups.create_confirm', 'Создать резервную копию всех клиентов на этом сервере?'),
('ru', 'backups.created_success', 'Резервная копия успешно создана'),
('ru', 'backups.delete_confirm', 'Удалить эту резервную копию навсегда?'),
('ru', 'backups.deleted_success', 'Резервная копия успешно удалена'),
('ru', 'backups.login_required', 'Пожалуйста, войдите через API для управления резервными копиями'),
('ru', 'backups.no_backups', 'Пока нет резервных копий'),
('ru', 'backups.restore', 'Восстановить'),
('ru', 'backups.restore_confirm', 'Восстановить клиентов из этой резервной копии? Существующие клиенты не будут затронуты.'),
('ru', 'backups.restored_success', 'Восстановлено'),
('ru', 'backups.title', 'Резервные копии сервера'),
('ru', 'clients.actions', 'Действия'),
('ru', 'clients.add', 'Добавить клиента'),
('ru', 'clients.create', 'Создать клиента'),
('ru', 'clients.delete', 'Удалить'),
('ru', 'clients.download_config', 'Скачать конфигурацию'),
('ru', 'clients.expiration', 'Срок действия'),
('ru', 'clients.expired', 'Истек'),
('ru', 'clients.ip', 'IP-адрес'),
('ru', 'clients.last_handshake', 'Последнее соединение'),
('ru', 'clients.name', 'Имя клиента'),
('ru', 'clients.never_expires', 'Бессрочно'),
('ru', 'clients.qr_code', 'QR-код'),
('ru', 'clients.received', 'Получено'),
('ru', 'clients.restore', 'Восстановить'),
('ru', 'clients.revoke', 'Отозвать'),
('ru', 'clients.sent', 'Отправлено'),
('ru', 'clients.server', 'Сервер'),
('ru', 'clients.status', 'Статус'),
('ru', 'clients.sync_stats', 'Синхронизировать статистику'),
('ru', 'clients.title', 'Клиенты'),
('ru', 'clients.traffic', 'Трафик'),
('ru', 'common.days', 'дней'),
('ru', 'dashboard.active_clients', 'Активные клиенты'),
('ru', 'dashboard.add_first_server', 'Добавить первый сервер'),
('ru', 'dashboard.get_started', 'Начните с добавления вашего первого VPN-сервера'),
('ru', 'dashboard.no_servers', 'Пока нет серверов'),
('ru', 'dashboard.quick_actions', 'Быстрые действия'),
('ru', 'dashboard.recent_servers', 'Недавние серверы'),
('ru', 'dashboard.title', 'Панель управления'),
('ru', 'dashboard.total_clients', 'Всего клиентов'),
('ru', 'dashboard.total_servers', 'Всего серверов'),
('ru', 'dashboard.total_traffic', 'Общий трафик'),
('ru', 'dashboard.welcome', 'Добро пожаловать в панель управления Amnezia VPN'),
('ru', 'form.cancel', 'Отмена'),
('ru', 'form.close', 'Закрыть'),
('ru', 'form.create', 'Создать'),
('ru', 'form.loading', 'Загрузка...'),
('ru', 'form.processing', 'Обработка...'),
('ru', 'form.save', 'Сохранить'),
('ru', 'form.submit', 'Отправить'),
('ru', 'form.update', 'Обновить'),
('ru', 'menu.clients', 'Клиенты'),
('ru', 'menu.dashboard', 'Панель управления'),
('ru', 'menu.logout', 'Выход'),
('ru', 'menu.servers', 'Серверы'),
('ru', 'menu.settings', 'Настройки'),
('ru', 'menu.users', 'Пользователи'),
('ru', 'message.confirm', 'Вы уверены?'),
('ru', 'message.deleted', 'Успешно удалено'),
('ru', 'message.deployed', 'Успешно развернуто'),
('ru', 'message.error', 'Произошла ошибка'),
('ru', 'message.saved', 'Успешно сохранено'),
('ru', 'message.success', 'Операция успешно завершена'),
('ru', 'servers.actions', 'Действия'),
('ru', 'servers.add', 'Добавить сервер'),
('ru', 'servers.clients', 'Клиенты'),
('ru', 'servers.delete', 'Удалить'),
('ru', 'servers.deploy', 'Развернуть'),
('ru', 'servers.edit', 'Редактировать'),
('ru', 'servers.host', 'Хост'),
('ru', 'servers.name', 'Имя'),
('ru', 'servers.port', 'Порт'),
('ru', 'servers.status', 'Статус'),
('ru', 'servers.title', 'Серверы'),
('ru', 'servers.view', 'Просмотр'),
('ru', 'settings.actions', 'Действия'),
('ru', 'settings.api_key_configured', 'API-ключ настроен'),
('ru', 'settings.api_keys', 'API-ключи'),
('ru', 'settings.api_keys_desc', 'Настройка API-ключей для внешних сервисов'),
('ru', 'settings.auto_translate', 'Автоперевод'),
('ru', 'settings.change_password', 'Изменить пароль'),
('ru', 'settings.confirm_password', 'Подтвердите пароль'),
('ru', 'settings.confirm_translate', 'Начать автоматический перевод? Это может занять несколько минут.'),
('ru', 'settings.current_password', 'Текущий пароль'),
('ru', 'settings.description', 'Управление конфигурацией панели и интеграциями API'),
('ru', 'settings.error_empty_key', 'API-ключ не может быть пустым'),
('ru', 'settings.error_invalid_key', 'Неверный формат API-ключа'),
('ru', 'settings.error_key_test', 'Тест API-ключа не удался'),
('ru', 'settings.for_translation', 'для автоперевода'),
('ru', 'settings.get_key_at', 'Получите ваш API-ключ на'),
('ru', 'settings.key_saved', 'API-ключ успешно сохранен'),
('ru', 'settings.keys', 'ключи'),
('ru', 'settings.language', 'Язык'),
('ru', 'settings.min_6_chars', 'Минимум 6 символов'),
('ru', 'settings.new_password', 'Новый пароль'),
('ru', 'settings.no_api_key', 'API-ключ не настроен. Автоперевод не будет работать.'),
('ru', 'settings.profile', 'Профиль'),
('ru', 'settings.progress', 'Прогресс'),
('ru', 'settings.skip_validation', 'Пропустить проверку (сохранить без тестирования)'),
('ru', 'settings.translation_complete', 'Перевод завершен'),
('ru', 'settings.translation_status', 'Статус перевода'),
('ru', 'settings.translations', 'Переводы'),
('ru', 'settings.users', 'Пользователи'),
('ru', 'status.active', 'Активен'),
('ru', 'status.deploying', 'Развертывание'),
('ru', 'status.disabled', 'Отключен'),
('ru', 'status.error', 'Ошибка'),
('ru', 'status.inactive', 'Неактивен'),
('ru', 'users.add_user', 'Добавить пользователя'),
('ru', 'users.administrator', 'Администратор'),
('ru', 'users.all_users', 'Все пользователи'),
('ru', 'users.created', 'Создан'),
('ru', 'users.delete_confirm', 'Удалить {0}?'),
('ru', 'users.role', 'Роль'),
('ru', 'users.role_admin', 'Администратор'),
('ru', 'users.role_user', 'Пользователь')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+127
View File
@@ -0,0 +1,127 @@
-- French translations
-- This migration adds French language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('fr', 'auth.email', 'Email'),
('fr', 'auth.login', 'Connexion'),
('fr', 'auth.name', 'Nom'),
('fr', 'auth.password', 'Mot de passe'),
('fr', 'auth.register', 'S''inscrire'),
('fr', 'backups.create', 'Créer une sauvegarde'),
('fr', 'backups.create_confirm', 'Créer une sauvegarde de tous les clients sur ce serveur ?'),
('fr', 'backups.created_success', 'Sauvegarde créée avec succès'),
('fr', 'backups.delete_confirm', 'Supprimer définitivement cette sauvegarde ?'),
('fr', 'backups.deleted_success', 'Sauvegarde supprimée avec succès'),
('fr', 'backups.login_required', 'Veuillez vous connecter via l''API pour gérer les sauvegardes'),
('fr', 'backups.no_backups', 'Aucune sauvegarde pour le moment'),
('fr', 'backups.restore', 'Restaurer'),
('fr', 'backups.restore_confirm', 'Restaurer les clients depuis cette sauvegarde ? Les clients existants ne seront pas affectés.'),
('fr', 'backups.restored_success', 'Restauré'),
('fr', 'backups.title', 'Sauvegardes du serveur'),
('fr', 'clients.actions', 'Actions'),
('fr', 'clients.add', 'Ajouter un client'),
('fr', 'clients.create', 'Créer un client'),
('fr', 'clients.delete', 'Supprimer'),
('fr', 'clients.download_config', 'Télécharger la configuration'),
('fr', 'clients.expiration', 'Expiration'),
('fr', 'clients.expired', 'Expiré'),
('fr', 'clients.ip', 'Adresse IP'),
('fr', 'clients.last_handshake', 'Dernière connexion'),
('fr', 'clients.name', 'Nom du client'),
('fr', 'clients.never_expires', 'N''expire jamais'),
('fr', 'clients.qr_code', 'Code QR'),
('fr', 'clients.received', 'Reçu'),
('fr', 'clients.restore', 'Restaurer'),
('fr', 'clients.revoke', 'Révoquer'),
('fr', 'clients.sent', 'Envoyé'),
('fr', 'clients.server', 'Serveur'),
('fr', 'clients.status', 'Statut'),
('fr', 'clients.sync_stats', 'Synchroniser les statistiques'),
('fr', 'clients.title', 'Clients'),
('fr', 'clients.traffic', 'Trafic'),
('fr', 'common.days', 'jours'),
('fr', 'dashboard.active_clients', 'Clients actifs'),
('fr', 'dashboard.add_first_server', 'Ajouter le premier serveur'),
('fr', 'dashboard.get_started', 'Commencez par ajouter votre premier serveur VPN'),
('fr', 'dashboard.no_servers', 'Aucun serveur pour le moment'),
('fr', 'dashboard.quick_actions', 'Actions rapides'),
('fr', 'dashboard.recent_servers', 'Serveurs récents'),
('fr', 'dashboard.title', 'Tableau de bord'),
('fr', 'dashboard.total_clients', 'Total des clients'),
('fr', 'dashboard.total_servers', 'Total des serveurs'),
('fr', 'dashboard.total_traffic', 'Trafic total'),
('fr', 'dashboard.welcome', 'Bienvenue sur le panneau de gestion Amnezia VPN'),
('fr', 'form.cancel', 'Annuler'),
('fr', 'form.close', 'Fermer'),
('fr', 'form.create', 'Créer'),
('fr', 'form.loading', 'Chargement...'),
('fr', 'form.processing', 'Traitement...'),
('fr', 'form.save', 'Enregistrer'),
('fr', 'form.submit', 'Soumettre'),
('fr', 'form.update', 'Mettre à jour'),
('fr', 'menu.clients', 'Clients'),
('fr', 'menu.dashboard', 'Tableau de bord'),
('fr', 'menu.logout', 'Déconnexion'),
('fr', 'menu.servers', 'Serveurs'),
('fr', 'menu.settings', 'Paramètres'),
('fr', 'menu.users', 'Utilisateurs'),
('fr', 'message.confirm', 'Êtes-vous sûr ?'),
('fr', 'message.deleted', 'Supprimé avec succès'),
('fr', 'message.deployed', 'Déployé avec succès'),
('fr', 'message.error', 'Une erreur est survenue'),
('fr', 'message.saved', 'Enregistré avec succès'),
('fr', 'message.success', 'Opération terminée avec succès'),
('fr', 'servers.actions', 'Actions'),
('fr', 'servers.add', 'Ajouter un serveur'),
('fr', 'servers.clients', 'Clients'),
('fr', 'servers.delete', 'Supprimer'),
('fr', 'servers.deploy', 'Déployer'),
('fr', 'servers.edit', 'Modifier'),
('fr', 'servers.host', 'Hôte'),
('fr', 'servers.name', 'Nom'),
('fr', 'servers.port', 'Port'),
('fr', 'servers.status', 'Statut'),
('fr', 'servers.title', 'Serveurs'),
('fr', 'servers.view', 'Voir'),
('fr', 'settings.actions', 'Actions'),
('fr', 'settings.api_key_configured', 'Clé API configurée'),
('fr', 'settings.api_keys', 'Clés API'),
('fr', 'settings.api_keys_desc', 'Configurer les clés API pour les services externes'),
('fr', 'settings.auto_translate', 'Traduction automatique'),
('fr', 'settings.change_password', 'Changer le mot de passe'),
('fr', 'settings.confirm_password', 'Confirmer le mot de passe'),
('fr', 'settings.confirm_translate', 'Démarrer la traduction automatique ? Cela peut prendre quelques minutes.'),
('fr', 'settings.current_password', 'Mot de passe actuel'),
('fr', 'settings.description', 'Gérer la configuration du panneau et les intégrations API'),
('fr', 'settings.error_empty_key', 'La clé API ne peut pas être vide'),
('fr', 'settings.error_invalid_key', 'Format de clé API invalide'),
('fr', 'settings.error_key_test', 'Test de la clé API échoué'),
('fr', 'settings.for_translation', 'pour la traduction automatique'),
('fr', 'settings.get_key_at', 'Obtenez votre clé API sur'),
('fr', 'settings.key_saved', 'Clé API enregistrée avec succès'),
('fr', 'settings.keys', 'clés'),
('fr', 'settings.language', 'Langue'),
('fr', 'settings.min_6_chars', 'Minimum 6 caractères'),
('fr', 'settings.new_password', 'Nouveau mot de passe'),
('fr', 'settings.no_api_key', 'Aucune clé API configurée. La traduction automatique ne fonctionnera pas.'),
('fr', 'settings.profile', 'Profil'),
('fr', 'settings.progress', 'Progression'),
('fr', 'settings.skip_validation', 'Ignorer la validation (enregistrer sans tester)'),
('fr', 'settings.translation_complete', 'Traduction terminée'),
('fr', 'settings.translation_status', 'État de la traduction'),
('fr', 'settings.translations', 'Traductions'),
('fr', 'settings.users', 'Utilisateurs'),
('fr', 'status.active', 'Actif'),
('fr', 'status.deploying', 'Déploiement'),
('fr', 'status.disabled', 'Désactivé'),
('fr', 'status.error', 'Erreur'),
('fr', 'status.inactive', 'Inactif'),
('fr', 'users.add_user', 'Ajouter un utilisateur'),
('fr', 'users.administrator', 'Administrateur'),
('fr', 'users.all_users', 'Tous les utilisateurs'),
('fr', 'users.created', 'Créé'),
('fr', 'users.delete_confirm', 'Supprimer {0} ?'),
('fr', 'users.role', 'Rôle'),
('fr', 'users.role_admin', 'Administrateur'),
('fr', 'users.role_user', 'Utilisateur')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+131
View File
@@ -0,0 +1,131 @@
-- French translations
-- This migration adds French language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('fr', 'auth.email', 'Email'),
('fr', 'auth.login', 'Connexion'),
('fr', 'auth.name', 'Nom'),
('fr', 'auth.password', 'Mot de passe'),
('fr', 'auth.register', 'S''inscrire'),
('fr', 'backups.create', 'Créer une sauvegarde'),
('fr', 'backups.create_confirm', 'Créer une sauvegarde de tous les clients sur ce serveur ?'),
('fr', 'backups.created_success', 'Sauvegarde créée avec succès'),
('fr', 'backups.delete_confirm', 'Supprimer définitivement cette sauvegarde ?'),
('fr', 'backups.deleted_success', 'Sauvegarde supprimée avec succès'),
('fr', 'backups.login_required', 'Veuillez vous connecter via l''API pour gérer les sauvegardes'),
('fr', 'backups.no_backups', 'Aucune sauvegarde pour le moment'),
('fr', 'backups.restore', 'Restaurer'),
('fr', 'backups.restore_confirm', 'Restaurer les clients depuis cette sauvegarde ? Les clients existants ne seront pas affectés.'),
('fr', 'backups.restored_success', 'Restauré'),
('fr', 'backups.title', 'Sauvegardes du serveur'),
('fr', 'clients.actions', 'Actions'),
('fr', 'clients.add', 'Ajouter un client'),
('fr', 'clients.create', 'Créer un client'),
('fr', 'clients.delete', 'Supprimer'),
('fr', 'clients.delete_confirm', 'Supprimer ce client définitivement?'),
('fr', 'clients.download_config', 'Télécharger la configuration'),
('fr', 'clients.expiration', 'Expiration'),
('fr', 'clients.expired', 'Expiré'),
('fr', 'clients.ip', 'Adresse IP'),
('fr', 'clients.last_handshake', 'Dernière connexion'),
('fr', 'clients.name', 'Nom du client'),
('fr', 'clients.never', 'Jamais'),
('fr', 'clients.never_expires', 'N''expire jamais'),
('fr', 'clients.no_clients', 'Pas encore de clients'),
('fr', 'clients.qr_code', 'Code QR'),
('fr', 'clients.received', 'Reçu'),
('fr', 'clients.restore', 'Restaurer'),
('fr', 'clients.revoke', 'Révoquer'),
('fr', 'clients.revoke_confirm', 'Révoquer l''accès pour ce client?'),
('fr', 'clients.sent', 'Envoyé'),
('fr', 'clients.server', 'Serveur'),
('fr', 'clients.status', 'Statut'),
('fr', 'clients.sync_stats', 'Synchroniser les statistiques'),
('fr', 'clients.title', 'Clients'),
('fr', 'clients.traffic', 'Trafic'),
('fr', 'common.days', 'jours'),
('fr', 'dashboard.active_clients', 'Clients actifs'),
('fr', 'dashboard.add_first_server', 'Ajouter le premier serveur'),
('fr', 'dashboard.get_started', 'Commencez par ajouter votre premier serveur VPN'),
('fr', 'dashboard.no_servers', 'Aucun serveur pour le moment'),
('fr', 'dashboard.quick_actions', 'Actions rapides'),
('fr', 'dashboard.recent_servers', 'Serveurs récents'),
('fr', 'dashboard.title', 'Tableau de bord'),
('fr', 'dashboard.total_clients', 'Total des clients'),
('fr', 'dashboard.total_servers', 'Total des serveurs'),
('fr', 'dashboard.total_traffic', 'Trafic total'),
('fr', 'dashboard.welcome', 'Bienvenue sur le panneau de gestion Amnezia VPN'),
('fr', 'form.cancel', 'Annuler'),
('fr', 'form.close', 'Fermer'),
('fr', 'form.create', 'Créer'),
('fr', 'form.loading', 'Chargement...'),
('fr', 'form.processing', 'Traitement...'),
('fr', 'form.save', 'Enregistrer'),
('fr', 'form.submit', 'Soumettre'),
('fr', 'form.update', 'Mettre à jour'),
('fr', 'menu.clients', 'Clients'),
('fr', 'menu.dashboard', 'Tableau de bord'),
('fr', 'menu.logout', 'Déconnexion'),
('fr', 'menu.servers', 'Serveurs'),
('fr', 'menu.settings', 'Paramètres'),
('fr', 'menu.users', 'Utilisateurs'),
('fr', 'message.confirm', 'Êtes-vous sûr ?'),
('fr', 'message.deleted', 'Supprimé avec succès'),
('fr', 'message.deployed', 'Déployé avec succès'),
('fr', 'message.error', 'Une erreur est survenue'),
('fr', 'message.saved', 'Enregistré avec succès'),
('fr', 'message.success', 'Opération terminée avec succès'),
('fr', 'servers.actions', 'Actions'),
('fr', 'servers.add', 'Ajouter un serveur'),
('fr', 'servers.clients', 'Clients'),
('fr', 'servers.delete', 'Supprimer'),
('fr', 'servers.deploy', 'Déployer'),
('fr', 'servers.edit', 'Modifier'),
('fr', 'servers.host', 'Hôte'),
('fr', 'servers.name', 'Nom'),
('fr', 'servers.port', 'Port'),
('fr', 'servers.status', 'Statut'),
('fr', 'servers.title', 'Serveurs'),
('fr', 'servers.view', 'Voir'),
('fr', 'settings.actions', 'Actions'),
('fr', 'settings.api_key_configured', 'Clé API configurée'),
('fr', 'settings.api_keys', 'Clés API'),
('fr', 'settings.api_keys_desc', 'Configurer les clés API pour les services externes'),
('fr', 'settings.auto_translate', 'Traduction automatique'),
('fr', 'settings.change_password', 'Changer le mot de passe'),
('fr', 'settings.confirm_password', 'Confirmer le mot de passe'),
('fr', 'settings.confirm_translate', 'Démarrer la traduction automatique ? Cela peut prendre quelques minutes.'),
('fr', 'settings.current_password', 'Mot de passe actuel'),
('fr', 'settings.description', 'Gérer la configuration du panneau et les intégrations API'),
('fr', 'settings.error_empty_key', 'La clé API ne peut pas être vide'),
('fr', 'settings.error_invalid_key', 'Format de clé API invalide'),
('fr', 'settings.error_key_test', 'Test de la clé API échoué'),
('fr', 'settings.for_translation', 'pour la traduction automatique'),
('fr', 'settings.get_key_at', 'Obtenez votre clé API sur'),
('fr', 'settings.key_saved', 'Clé API enregistrée avec succès'),
('fr', 'settings.keys', 'clés'),
('fr', 'settings.language', 'Langue'),
('fr', 'settings.min_6_chars', 'Minimum 6 caractères'),
('fr', 'settings.new_password', 'Nouveau mot de passe'),
('fr', 'settings.no_api_key', 'Aucune clé API configurée. La traduction automatique ne fonctionnera pas.'),
('fr', 'settings.profile', 'Profil'),
('fr', 'settings.progress', 'Progression'),
('fr', 'settings.skip_validation', 'Ignorer la validation (enregistrer sans tester)'),
('fr', 'settings.translation_complete', 'Traduction terminée'),
('fr', 'settings.translation_status', 'État de la traduction'),
('fr', 'settings.translations', 'Traductions'),
('fr', 'settings.users', 'Utilisateurs'),
('fr', 'status.active', 'Actif'),
('fr', 'status.deploying', 'Déploiement'),
('fr', 'status.disabled', 'Désactivé'),
('fr', 'status.error', 'Erreur'),
('fr', 'status.inactive', 'Inactif'),
('fr', 'users.add_user', 'Ajouter un utilisateur'),
('fr', 'users.administrator', 'Administrateur'),
('fr', 'users.all_users', 'Tous les utilisateurs'),
('fr', 'users.created', 'Créé'),
('fr', 'users.delete_confirm', 'Supprimer {0} ?'),
('fr', 'users.role', 'Rôle'),
('fr', 'users.role_admin', 'Administrateur'),
('fr', 'users.role_user', 'Utilisateur')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+131
View File
@@ -0,0 +1,131 @@
-- Chinese translations
-- This migration adds Chinese language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('zh', 'auth.email', '邮箱'),
('zh', 'auth.login', '登录'),
('zh', 'auth.name', '姓名'),
('zh', 'auth.password', '密码'),
('zh', 'auth.register', '注册'),
('zh', 'backups.create', '创建备份'),
('zh', 'backups.create_confirm', '创建此服务器上所有客户端的备份?'),
('zh', 'backups.created_success', '备份创建成功'),
('zh', 'backups.delete_confirm', '永久删除此备份?'),
('zh', 'backups.deleted_success', '备份删除成功'),
('zh', 'backups.login_required', '请通过API登录以管理备份'),
('zh', 'backups.no_backups', '暂无备份'),
('zh', 'backups.restore', '恢复'),
('zh', 'backups.restore_confirm', '从此备份恢复客户端?现有客户端不会受到影响。'),
('zh', 'backups.restored_success', '已恢复'),
('zh', 'backups.title', '服务器备份'),
('zh', 'clients.actions', '操作'),
('zh', 'clients.add', '添加客户端'),
('zh', 'clients.create', '创建客户端'),
('zh', 'clients.delete', '删除'),
('zh', 'clients.delete_confirm', '永久删除此客户端?'),
('zh', 'clients.download_config', '下载配置'),
('zh', 'clients.expiration', '过期时间'),
('zh', 'clients.expired', '已过期'),
('zh', 'clients.ip', 'IP地址'),
('zh', 'clients.last_handshake', '最后握手'),
('zh', 'clients.name', '客户端名称'),
('zh', 'clients.never', '从不'),
('zh', 'clients.never_expires', '永不过期'),
('zh', 'clients.no_clients', '还没有客户'),
('zh', 'clients.qr_code', '二维码'),
('zh', 'clients.received', '已接收'),
('zh', 'clients.restore', '恢复'),
('zh', 'clients.revoke', '撤销'),
('zh', 'clients.revoke_confirm', '撤销此客户端的访问权限?'),
('zh', 'clients.sent', '已发送'),
('zh', 'clients.server', '服务器'),
('zh', 'clients.status', '状态'),
('zh', 'clients.sync_stats', '同步统计'),
('zh', 'clients.title', '客户端'),
('zh', 'clients.traffic', '流量'),
('zh', 'common.days', ''),
('zh', 'dashboard.active_clients', '活跃客户端'),
('zh', 'dashboard.add_first_server', '添加第一个服务器'),
('zh', 'dashboard.get_started', '从添加第一个VPN服务器开始'),
('zh', 'dashboard.no_servers', '暂无服务器'),
('zh', 'dashboard.quick_actions', '快捷操作'),
('zh', 'dashboard.recent_servers', '最近的服务器'),
('zh', 'dashboard.title', '仪表板'),
('zh', 'dashboard.total_clients', '客户端总数'),
('zh', 'dashboard.total_servers', '服务器总数'),
('zh', 'dashboard.total_traffic', '总流量'),
('zh', 'dashboard.welcome', '欢迎使用Amnezia VPN管理面板'),
('zh', 'form.cancel', '取消'),
('zh', 'form.close', '关闭'),
('zh', 'form.create', '创建'),
('zh', 'form.loading', '加载中...'),
('zh', 'form.processing', '处理中...'),
('zh', 'form.save', '保存'),
('zh', 'form.submit', '提交'),
('zh', 'form.update', '更新'),
('zh', 'menu.clients', '客户端'),
('zh', 'menu.dashboard', '仪表板'),
('zh', 'menu.logout', '退出'),
('zh', 'menu.servers', '服务器'),
('zh', 'menu.settings', '设置'),
('zh', 'menu.users', '用户'),
('zh', 'message.confirm', '确定吗?'),
('zh', 'message.deleted', '删除成功'),
('zh', 'message.deployed', '部署成功'),
('zh', 'message.error', '发生错误'),
('zh', 'message.saved', '保存成功'),
('zh', 'message.success', '操作完成'),
('zh', 'servers.actions', '操作'),
('zh', 'servers.add', '添加服务器'),
('zh', 'servers.clients', '客户端'),
('zh', 'servers.delete', '删除'),
('zh', 'servers.deploy', '部署'),
('zh', 'servers.edit', '编辑'),
('zh', 'servers.host', '主机'),
('zh', 'servers.name', '名称'),
('zh', 'servers.port', '端口'),
('zh', 'servers.status', '状态'),
('zh', 'servers.title', '服务器'),
('zh', 'servers.view', '查看'),
('zh', 'settings.actions', '操作'),
('zh', 'settings.api_key_configured', 'API密钥已配置'),
('zh', 'settings.api_keys', 'API密钥'),
('zh', 'settings.api_keys_desc', '配置外部服务的API密钥'),
('zh', 'settings.auto_translate', '自动翻译'),
('zh', 'settings.change_password', '修改密码'),
('zh', 'settings.confirm_password', '确认密码'),
('zh', 'settings.confirm_translate', '开始自动翻译?这可能需要几分钟。'),
('zh', 'settings.current_password', '当前密码'),
('zh', 'settings.description', '管理面板配置和API集成'),
('zh', 'settings.error_empty_key', 'API密钥不能为空'),
('zh', 'settings.error_invalid_key', '无效的API密钥格式'),
('zh', 'settings.error_key_test', 'API密钥测试失败'),
('zh', 'settings.for_translation', '用于自动翻译'),
('zh', 'settings.get_key_at', '在此获取API密钥'),
('zh', 'settings.key_saved', 'API密钥保存成功'),
('zh', 'settings.keys', '密钥'),
('zh', 'settings.language', '语言'),
('zh', 'settings.min_6_chars', '最少6个字符'),
('zh', 'settings.new_password', '新密码'),
('zh', 'settings.no_api_key', '未配置API密钥。自动翻译将无法工作。'),
('zh', 'settings.profile', '个人资料'),
('zh', 'settings.progress', '进度'),
('zh', 'settings.skip_validation', '跳过验证(不测试直接保存)'),
('zh', 'settings.translation_complete', '翻译完成'),
('zh', 'settings.translation_status', '翻译状态'),
('zh', 'settings.translations', '翻译'),
('zh', 'settings.users', '用户'),
('zh', 'status.active', '活跃'),
('zh', 'status.deploying', '部署中'),
('zh', 'status.disabled', '已禁用'),
('zh', 'status.error', '错误'),
('zh', 'status.inactive', '不活跃'),
('zh', 'users.add_user', '添加用户'),
('zh', 'users.administrator', '管理员'),
('zh', 'users.all_users', '所有用户'),
('zh', 'users.created', '已创建'),
('zh', 'users.delete_confirm', '删除 {0}'),
('zh', 'users.role', '角色'),
('zh', 'users.role_admin', '管理员'),
('zh', 'users.role_user', '用户')
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
+54
View File
@@ -0,0 +1,54 @@
# Database Migrations
This directory contains SQL migration files that are automatically executed when the database container is first initialized.
## Execution Order
Migration files are executed in **alphabetical order** by MySQL's Docker entrypoint. Files are numbered to ensure correct execution sequence:
1. `001_init.sql` - Main database schema and tables
2. `002_translations_ru.sql` - Russian translations
3. `003_translations_es.sql` - Spanish translations
4. `004_translations_de.sql` - German translations
5. `005_translations_fr.sql` - French translations
6. `006_translations_zh.sql` - Chinese translations
## Adding New Migrations
When creating new migration files:
1. Use numerical prefix (e.g., `007_add_feature.sql`)
2. Ensure the number is higher than existing migrations
3. Use descriptive names
4. Always use `ON DUPLICATE KEY UPDATE` for INSERT statements to make migrations idempotent
## Manual Execution
To manually run migrations in an existing database:
```bash
# Single migration
docker compose exec db mysql -uroot -prootpassword amnezia_panel < migrations/001_init.sql
# All migrations in order
for file in migrations/*.sql; do
echo "Executing $file..."
docker compose exec -T db mysql -uroot -prootpassword amnezia_panel < "$file"
done
```
## Regenerating Translation Migrations
To regenerate translation migrations from the current database:
```bash
# Export translations for a specific language
docker compose exec -T db mysql -uroot -prootpassword amnezia_panel \
--default-character-set=utf8mb4 \
-e "SELECT CONCAT('(''', language_code, ''', ''', translation_key, ''', ''',
REPLACE(translation_value, '''', ''''''), '''),')
FROM translations WHERE language_code = 'ru' ORDER BY translation_key;" \
| grep -v "CONCAT" > /tmp/translations_ru.sql
# Then wrap with INSERT statement and ON DUPLICATE KEY UPDATE
```
+12
View File
@@ -0,0 +1,12 @@
[client]
default-character-set = utf8mb4
[mysql]
default-character-set = utf8mb4
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init_connect = 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci'
init_connect = 'SET collation_connection = utf8mb4_unicode_ci'
skip-character-set-client-handshake
+304 -3
View File
@@ -81,6 +81,39 @@ function requireAdmin(): void {
} }
} }
// Helper function to get authenticated user (JWT or session)
function getAuthUser(): ?array {
// Try JWT first
$token = JWT::getTokenFromHeader();
if ($token !== null) {
$user = JWT::verify($token);
if ($user !== null) {
return $user;
}
}
// Fall back to session
if (Auth::check()) {
return Auth::user();
}
return null;
}
// Helper function to require authentication (JWT or session) for API
function requireApiAuth(): ?array {
$user = getAuthUser();
if ($user === null) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Authentication required']);
return null;
}
return $user;
}
/** /**
* PUBLIC ROUTES * PUBLIC ROUTES
*/ */
@@ -327,8 +360,9 @@ Router::get('/servers/{id}', function ($params) {
'clients' => $clients, 'clients' => $clients,
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
error_log('Server view error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine());
http_response_code(404); http_response_code(404);
echo 'Server not found'; echo 'Server not found: ' . htmlspecialchars($e->getMessage());
} }
}); });
@@ -361,6 +395,7 @@ Router::post('/servers/{id}/clients/create', function ($params) {
requireAuth(); requireAuth();
$serverId = (int)$params['id']; $serverId = (int)$params['id'];
$clientName = trim($_POST['name'] ?? ''); $clientName = trim($_POST['name'] ?? '');
$expiresInDays = !empty($_POST['expires_in_days']) ? (int)$_POST['expires_in_days'] : null;
if (empty($clientName)) { if (empty($clientName)) {
redirect('/servers/' . $serverId . '?error=Client+name+is+required'); redirect('/servers/' . $serverId . '?error=Client+name+is+required');
@@ -379,7 +414,7 @@ Router::post('/servers/{id}/clients/create', function ($params) {
return; return;
} }
$clientId = VpnClient::create($serverId, $user['id'], $clientName); $clientId = VpnClient::create($serverId, $user['id'], $clientName, $expiresInDays);
redirect('/clients/' . $clientId); redirect('/clients/' . $clientId);
} catch (Exception $e) { } catch (Exception $e) {
redirect('/servers/' . $serverId . '?error=' . urlencode($e->getMessage())); redirect('/servers/' . $serverId . '?error=' . urlencode($e->getMessage()));
@@ -768,6 +803,157 @@ Router::delete('/api/servers/{id}/delete', function ($params) {
} }
}); });
// API: Create backup
Router::post('/api/servers/{id}/backup', function ($params) {
header('Content-Type: application/json');
$user = requireApiAuth();
if (!$user) return;
$serverId = (int)$params['id'];
try {
$server = new VpnServer($serverId);
$serverData = $server->getData();
// Check ownership or admin
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
$backupId = $server->createBackup($user['id'], 'manual');
$backup = VpnServer::getBackup($backupId);
echo json_encode([
'success' => true,
'backup' => $backup
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: List backups
Router::get('/api/servers/{id}/backups', function ($params) {
header('Content-Type: application/json');
$user = requireApiAuth();
if (!$user) return;
$serverId = (int)$params['id'];
try {
$server = new VpnServer($serverId);
$serverData = $server->getData();
// Check ownership or admin
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
$backups = $server->listBackups();
echo json_encode([
'success' => true,
'backups' => $backups,
'count' => count($backups)
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: Restore backup
Router::post('/api/servers/{id}/restore', function ($params) {
header('Content-Type: application/json');
$user = requireApiAuth();
if (!$user) return;
$serverId = (int)$params['id'];
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$backupId = (int)($data['backup_id'] ?? 0);
if ($backupId <= 0) {
http_response_code(400);
echo json_encode(['error' => 'backup_id is required']);
return;
}
try {
$server = new VpnServer($serverId);
$serverData = $server->getData();
// Check ownership or admin
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
$result = $server->restoreBackup($backupId);
// Log the result for debugging
error_log('Restore backup result: ' . json_encode($result));
// Always return the result, even if success is false
echo json_encode($result);
} catch (Exception $e) {
error_log('Restore backup exception: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => $e->getMessage(), 'success' => false]);
}
});
// API: Delete backup
Router::delete('/api/backups/{id}', function ($params) {
header('Content-Type: application/json');
$user = requireApiAuth();
if (!$user) return;
$backupId = (int)$params['id'];
try {
$backup = VpnServer::getBackup($backupId);
if (!$backup) {
http_response_code(404);
echo json_encode(['error' => 'Backup not found']);
return;
}
// Get server to check ownership
$server = new VpnServer($backup['server_id']);
$serverData = $server->getData();
// Check ownership or admin
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
VpnServer::deleteBackup($backupId);
echo json_encode([
'success' => true,
'message' => 'Backup deleted successfully'
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// API: List clients // API: List clients
Router::get('/api/clients', function () { Router::get('/api/clients', function () {
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -988,6 +1174,7 @@ Router::post('/api/clients/create', function () {
$serverId = (int)($data['server_id'] ?? 0); $serverId = (int)($data['server_id'] ?? 0);
$name = trim($data['name'] ?? ''); $name = trim($data['name'] ?? '');
$expiresInDays = isset($data['expires_in_days']) ? (int)$data['expires_in_days'] : null;
if ($serverId <= 0 || empty($name)) { if ($serverId <= 0 || empty($name)) {
http_response_code(400); http_response_code(400);
@@ -996,7 +1183,7 @@ Router::post('/api/clients/create', function () {
} }
try { try {
$clientId = VpnClient::create($serverId, $user['id'], $name); $clientId = VpnClient::create($serverId, $user['id'], $name, $expiresInDays);
$client = new VpnClient($clientId); $client = new VpnClient($clientId);
$clientData = $client->getData(); $clientData = $client->getData();
@@ -1010,6 +1197,7 @@ Router::post('/api/clients/create', function () {
'server_id' => $clientData['server_id'], 'server_id' => $clientData['server_id'],
'client_ip' => $clientData['client_ip'], 'client_ip' => $clientData['client_ip'],
'status' => $clientData['status'], 'status' => $clientData['status'],
'expires_at' => $clientData['expires_at'],
'created_at' => $clientData['created_at'], 'created_at' => $clientData['created_at'],
'config' => $clientData['config'], 'config' => $clientData['config'],
'qr_code' => $clientData['qr_code'], 'qr_code' => $clientData['qr_code'],
@@ -1021,6 +1209,119 @@ Router::post('/api/clients/create', function () {
} }
}); });
// Set client expiration
Router::post('/api/clients/{id}/set-expiration', function ($params) {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user) return;
$clientId = (int)$params['id'];
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$expiresAt = $data['expires_at'] ?? null; // Y-m-d H:i:s format or null
try {
$client = new VpnClient($clientId);
$clientData = $client->getData();
// Check ownership
if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
VpnClient::setExpiration($clientId, $expiresAt);
echo json_encode([
'success' => true,
'expires_at' => $expiresAt
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// Extend client expiration
Router::post('/api/clients/{id}/extend', function ($params) {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user) return;
$clientId = (int)$params['id'];
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$days = (int)($data['days'] ?? 30);
if ($days <= 0) {
http_response_code(400);
echo json_encode(['error' => 'days must be positive']);
return;
}
try {
$client = new VpnClient($clientId);
$clientData = $client->getData();
// Check ownership
if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
VpnClient::extendExpiration($clientId, $days);
// Get updated expiration
$client = new VpnClient($clientId);
$updated = $client->getData();
echo json_encode([
'success' => true,
'expires_at' => $updated['expires_at'],
'extended_days' => $days
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
// Get expiring clients
Router::get('/api/clients/expiring', function () {
header('Content-Type: application/json');
$user = JWT::requireAuth();
if (!$user) return;
$days = (int)($_GET['days'] ?? 7);
try {
$clients = VpnClient::getExpiringClients($days);
// Filter by user if not admin
if ($user['role'] !== 'admin') {
$clients = array_filter($clients, function($c) use ($user) {
return $c['user_id'] == $user['id'];
});
}
echo json_encode([
'success' => true,
'clients' => array_values($clients),
'count' => count($clients)
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
});
/** /**
* SETTINGS ROUTES * SETTINGS ROUTES
*/ */
+227 -21
View File
@@ -13,33 +13,61 @@
</dl> </dl>
</div> </div>
<div class="bg-white rounded shadow p-6"> <div class="bg-white rounded shadow p-6">
<h3 class="font-bold mb-4">Create Client</h3> <h3 class="font-bold mb-4">{{ t('clients.create') }}</h3>
<form method="POST" action="/servers/{{ server.id }}/clients/create" class="flex gap-2" id="createClientForm"> <form method="POST" action="/servers/{{ server.id }}/clients/create" class="space-y-3" id="createClientForm">
<input name="name" placeholder="Client name" required class="flex-1 px-3 py-2 border rounded" id="clientName"> <input name="name" placeholder="{{ t('clients.name') }}" required class="w-full px-3 py-2 border rounded" id="clientName">
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded" id="createClientBtn"> <div>
<span id="createClientText">Create</span> <label class="block text-sm text-gray-600 mb-1">{{ t('clients.expiration') }}</label>
<select name="expires_in_days" class="w-full px-3 py-2 border rounded">
<option value="" selected>{{ t('clients.never_expires') }}</option>
<option value="7">7 {{ t('common.days') }}</option>
<option value="30">30 {{ t('common.days') }}</option>
<option value="60">60 {{ t('common.days') }}</option>
<option value="90">90 {{ t('common.days') }}</option>
<option value="180">180 {{ t('common.days') }}</option>
<option value="365">365 {{ t('common.days') }}</option>
</select>
</div>
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded w-full" id="createClientBtn">
<span id="createClientText">{{ t('form.create') }}</span>
<i class="fas fa-spinner fa-spin" id="createClientSpinner" style="display:none;"></i> <i class="fas fa-spinner fa-spin" id="createClientSpinner" style="display:none;"></i>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<!-- Backup Section -->
<div class="bg-white rounded shadow mb-8">
<div class="px-6 py-4 border-b flex justify-between items-center">
<h3 class="font-bold">{{ t('backups.title') }}</h3>
<button onclick="createBackup({{ server.id }})" class="gradient-bg text-white px-4 py-2 rounded text-sm">
<i class="fas fa-save"></i> {{ t('backups.create') }}
</button>
</div>
<div id="backupsList" class="p-4">
<div class="text-center text-gray-500 py-4">
<i class="fas fa-spinner fa-spin"></i> {{ t('form.loading') }}
</div>
</div>
</div>
<div class="bg-white rounded shadow"> <div class="bg-white rounded shadow">
<div class="px-6 py-4 border-b flex justify-between items-center"> <div class="px-6 py-4 border-b flex justify-between items-center">
<h3 class="font-bold">Clients ({{ clients|length }})</h3> <h3 class="font-bold">{{ t('clients.title') }} ({{ clients|length }})</h3>
<button onclick="syncAllStats({{ server.id }})" class="text-purple-600 hover:text-purple-800 text-sm"> <button onclick="syncAllStats({{ server.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
<i class="fas fa-sync-alt"></i> Sync Stats <i class="fas fa-sync-alt"></i> {{ t('clients.sync_stats') }}
</button> </button>
</div> </div>
{% if clients|length > 0 %} {% if clients|length > 0 %}
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.ip') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.status') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Traffic</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.expiration') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Seen</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.last_handshake') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -49,9 +77,30 @@
<td class="px-6 py-4">{{ client.client_ip }}</td> <td class="px-6 py-4">{{ client.client_ip }}</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if client.status == 'active' %} {% if client.status == 'active' %}
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">Active</span> <span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">{{ t('status.active') }}</span>
{% else %} {% else %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">Disabled</span> <span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">{{ t('status.disabled') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{% if client.expires_at %}
{% set expires_ts = client.expires_at|date('U') %}
{% set now_ts = "now"|date('U') %}
{% set diff_days = ((expires_ts - now_ts) / 86400)|round %}
{% if diff_days < 0 %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
<i class="fas fa-exclamation-circle"></i> {{ t('clients.expired') }}
</span>
{% elseif diff_days <= 7 %}
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
<i class="fas fa-clock"></i> {{ diff_days }} {{ t('common.days') }}
</span>
{% else %}
<span class="text-gray-600">{{ client.expires_at|date('Y-m-d') }}</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.never_expires') }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 text-sm"> <td class="px-6 py-4 text-sm">
@@ -66,22 +115,22 @@
{% if client.last_handshake %} {% if client.last_handshake %}
<span class="text-gray-600">{{ client.last_handshake }}</span> <span class="text-gray-600">{{ client.last_handshake }}</span>
{% else %} {% else %}
<span class="text-gray-400">Never</span> <span class="text-gray-400">{{ t('clients.never') }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<a href="/clients/{{ client.id }}" class="text-purple-600 hover:text-purple-800 mr-2">View</a> <a href="/clients/{{ client.id }}" class="text-purple-600 hover:text-purple-800 mr-2">{{ t('servers.view') }}</a>
{% if client.status == 'active' %} {% if client.status == 'active' %}
<form method="POST" action="/clients/{{ client.id }}/revoke" style="display:inline;"> <form method="POST" action="/clients/{{ client.id }}/revoke" style="display:inline;">
<button type="submit" class="text-orange-600 hover:text-orange-800 mr-2" onclick="return confirm('Revoke access for this client?')">Revoke</button> <button type="submit" class="text-orange-600 hover:text-orange-800 mr-2" onclick="return confirm('{{ t('clients.revoke_confirm') }}')">{{ t('clients.revoke') }}</button>
</form> </form>
{% else %} {% else %}
<form method="POST" action="/clients/{{ client.id }}/restore" style="display:inline;"> <form method="POST" action="/clients/{{ client.id }}/restore" style="display:inline;">
<button type="submit" class="text-green-600 hover:text-green-800 mr-2">Restore</button> <button type="submit" class="text-green-600 hover:text-green-800 mr-2">{{ t('clients.restore') }}</button>
</form> </form>
{% endif %} {% endif %}
<form method="POST" action="/clients/{{ client.id }}/delete" style="display:inline;"> <form method="POST" action="/clients/{{ client.id }}/delete" style="display:inline;">
<button type="submit" class="text-red-600 hover:text-red-800" onclick="return confirm('Delete this client permanently?')">Delete</button> <button type="submit" class="text-red-600 hover:text-red-800" onclick="return confirm('{{ t('clients.delete_confirm') }}')">{{ t('clients.delete') }}</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -89,7 +138,7 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<div class="p-12 text-center text-gray-500">No clients yet</div> <div class="p-12 text-center text-gray-500">{{ t('clients.no_clients') }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -130,5 +179,162 @@ async function syncAllStats(serverId) {
alert('Error: ' + error.message); alert('Error: ' + error.message);
} }
} }
// Load backups on page load
document.addEventListener('DOMContentLoaded', function() {
loadBackups({{ server.id }});
});
async function loadBackups(serverId) {
try {
const response = await fetch(`/api/servers/${serverId}/backups`, {
credentials: 'same-origin'
});
if (response.status === 401) {
document.getElementById('backupsList').innerHTML = '<div class="text-center text-red-500 py-4">{{ t("message.error") }}: {{ t("backups.login_required") }}</div>';
return;
}
const data = await response.json();
if (data.success && data.backups.length > 0) {
let html = '<div class="space-y-2">';
data.backups.forEach(backup => {
const size = (backup.backup_size / 1024 / 1024).toFixed(2);
const statusClass = backup.status === 'completed' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
html += `
<div class="flex items-center justify-between p-3 border rounded">
<div class="flex-1">
<div class="font-medium">${backup.backup_name}</div>
<div class="text-sm text-gray-600">
${backup.clients_count} {{ t('clients.title') }} • ${size} MB • ${backup.created_at}
</div>
</div>
<div class="flex gap-2">
<span class="px-2 py-1 ${statusClass} rounded text-xs">${backup.status}</span>
${backup.status === 'completed' ? `
<button onclick="restoreBackup(${serverId}, ${backup.id})" class="text-blue-600 hover:text-blue-800 px-3 py-1 border rounded text-sm">
<i class="fas fa-undo"></i> {{ t('backups.restore') }}
</button>
` : ''}
<button onclick="deleteBackup(${backup.id})" class="text-red-600 hover:text-red-800 px-3 py-1 border rounded text-sm">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
});
html += '</div>';
document.getElementById('backupsList').innerHTML = html;
} else {
document.getElementById('backupsList').innerHTML = '<div class="text-center text-gray-500 py-4">{{ t("backups.no_backups") }}</div>';
}
} catch (error) {
document.getElementById('backupsList').innerHTML = `<div class="text-center text-red-500 py-4">Error: ${error.message}</div>`;
}
}
async function createBackup(serverId) {
if (!confirm('{{ t("backups.create_confirm") }}')) return;
// Show loading state
document.getElementById('backupsList').innerHTML = '<div class="text-center text-gray-500 py-4"><i class="fas fa-spinner fa-spin"></i> {{ t("form.processing") }}</div>';
try {
const response = await fetch(`/api/servers/${serverId}/backup`, {
method: 'POST',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
alert('{{ t("backups.created_success") }}');
loadBackups(serverId);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
loadBackups(serverId);
}
} catch (error) {
alert('Error: ' + error.message);
loadBackups(serverId);
}
}
async function restoreBackup(serverId, backupId) {
if (!confirm('{{ t("backups.restore_confirm") }}')) return;
try {
const response = await fetch(`/api/servers/${serverId}/restore`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ backup_id: backupId })
});
const data = await response.json();
console.log('Restore response:', data);
if (data.success !== false) {
let message = `{{ t("backups.restored_success") }}: ${data.restored} {{ t("clients.title") }}`;
if (data.failed > 0) {
message += `\n\nПропущено: ${data.failed}`;
if (data.errors && data.errors.length > 0) {
message += '\n\nПричины:\n' + data.errors.slice(0, 5).join('\n');
if (data.errors.length > 5) {
message += `\n... и ещё ${data.errors.length - 5}`;
}
}
}
alert(message);
if (data.restored > 0) {
location.reload();
} else {
loadBackups(serverId);
}
} else {
let errorMsg = 'Error: ';
if (data.error) {
errorMsg += data.error;
} else if (data.errors && data.errors.length > 0) {
errorMsg += data.errors.join(', ');
} else {
errorMsg += 'Unknown error';
}
alert(errorMsg);
}
} catch (error) {
console.error('Restore error:', error);
alert('Error: ' + error.message);
}
}
async function deleteBackup(backupId) {
if (!confirm('{{ t("backups.delete_confirm") }}')) return;
try {
const response = await fetch(`/api/backups/${backupId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
alert('{{ t("backups.deleted_success") }}');
loadBackups({{ server.id }});
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
</script> </script>
{% endblock %} {% endblock %}
+35 -29
View File
@@ -32,7 +32,7 @@
<nav class="-mb-px flex space-x-8"> <nav class="-mb-px flex space-x-8">
<a href="#" onclick="showTab('profile'); return false;" id="tab-profile" <a href="#" onclick="showTab('profile'); return false;" id="tab-profile"
class="tab-link border-purple-500 text-purple-600 py-4 px-1 border-b-2 font-medium text-sm"> class="tab-link border-purple-500 text-purple-600 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-user mr-2"></i>Profile <i class="fas fa-user mr-2"></i>{{ t('settings.profile') }}
</a> </a>
<a href="#" onclick="showTab('api'); return false;" id="tab-api" <a href="#" onclick="showTab('api'); return false;" id="tab-api"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm"> class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
@@ -40,12 +40,12 @@
</a> </a>
<a href="#" onclick="showTab('translations'); return false;" id="tab-translations" <a href="#" onclick="showTab('translations'); return false;" id="tab-translations"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm"> class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-language mr-2"></i>Translations <i class="fas fa-language mr-2"></i>{{ t('settings.translations') }}
</a> </a>
{% if user.role == 'admin' %} {% if user.role == 'admin' %}
<a href="#" onclick="showTab('users'); return false;" id="tab-users" <a href="#" onclick="showTab('users'); return false;" id="tab-users"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm"> class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-users mr-2"></i>Users <i class="fas fa-users mr-2"></i>{{ t('settings.users') }}
</a> </a>
{% endif %} {% endif %}
</nav> </nav>
@@ -56,30 +56,30 @@
<div class="bg-white shadow rounded-lg"> <div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200"> <div class="px-6 py-5 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900"> <h2 class="text-lg font-medium text-gray-900">
<i class="fas fa-lock mr-2 text-purple-600"></i>Change Password <i class="fas fa-lock mr-2 text-purple-600"></i>{{ t('settings.change_password') }}
</h2> </h2>
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
<form method="POST" action="/settings/change-password"> <form method="POST" action="/settings/change-password">
<div class="space-y-4 max-w-md"> <div class="space-y-4 max-w-md">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Current Password</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('settings.current_password') }}</label>
<input type="password" name="current_password" required <input type="password" name="current_password" required
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">New Password</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('settings.new_password') }}</label>
<input type="password" name="new_password" required minlength="6" <input type="password" name="new_password" required minlength="6"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
<p class="mt-1 text-xs text-gray-500">Minimum 6 characters</p> <p class="mt-1 text-xs text-gray-500">{{ t('settings.min_6_chars') }}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('settings.confirm_password') }}</label>
<input type="password" name="confirm_password" required minlength="6" <input type="password" name="confirm_password" required minlength="6"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div> </div>
<button type="submit" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700"> <button type="submit" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
<i class="fas fa-save mr-2"></i>Change Password <i class="fas fa-save mr-2"></i>{{ t('settings.change_password') }}
</button> </button>
</div> </div>
</form> </form>
@@ -101,7 +101,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<i class="fas fa-check-circle text-green-600 mr-2"></i> <i class="fas fa-check-circle text-green-600 mr-2"></i>
<span class="text-sm font-medium text-green-900">API Key Configured</span> <span class="text-sm font-medium text-green-900">{{ t('settings.api_key_configured') }}</span>
</div> </div>
<code class="text-xs text-green-700 bg-green-100 px-2 py-1 rounded"> <code class="text-xs text-green-700 bg-green-100 px-2 py-1 rounded">
{{ openrouter_key[:15] }}...{{ openrouter_key[-4:] }} {{ openrouter_key[:15] }}...{{ openrouter_key[-4:] }}
@@ -111,7 +111,7 @@
{% else %} {% else %}
<div class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md"> <div class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<i class="fas fa-exclamation-triangle text-yellow-600 mr-2"></i> <i class="fas fa-exclamation-triangle text-yellow-600 mr-2"></i>
<span class="text-sm text-yellow-800">No API key configured. Auto-translation will not work.</span> <span class="text-sm text-yellow-800">{{ t('settings.no_api_key') }}</span>
</div> </div>
{% endif %} {% endif %}
@@ -126,6 +126,12 @@
<i class="fas fa-info-circle"></i> {{ t('settings.get_key_at') }} <i class="fas fa-info-circle"></i> {{ t('settings.get_key_at') }}
<a href="https://openrouter.ai/keys" target="_blank" class="text-purple-600">openrouter.ai/keys</a> <a href="https://openrouter.ai/keys" target="_blank" class="text-purple-600">openrouter.ai/keys</a>
</p> </p>
<div class="mt-3">
<label class="inline-flex items-center">
<input type="checkbox" name="skip_test" value="1" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<span class="ml-2 text-sm text-gray-600">{{ t('settings.skip_validation') }}</span>
</label>
</div>
<input type="hidden" name="service" value="openrouter"> <input type="hidden" name="service" value="openrouter">
<button type="submit" class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700"> <button type="submit" class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
<i class="fas fa-save mr-2"></i>{{ openrouter_key ? t('form.update') : t('form.save') }} <i class="fas fa-save mr-2"></i>{{ openrouter_key ? t('form.update') : t('form.save') }}
@@ -194,34 +200,34 @@
<div class="bg-white shadow rounded-lg mb-6"> <div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-5 border-b border-gray-200"> <div class="px-6 py-5 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900"> <h2 class="text-lg font-medium text-gray-900">
<i class="fas fa-user-plus mr-2 text-purple-600"></i>Add User <i class="fas fa-user-plus mr-2 text-purple-600"></i>{{ t('users.add_user') }}
</h2> </h2>
</div> </div>
<div class="px-6 py-5"> <div class="px-6 py-5">
<form method="POST" action="/settings/add-user"> <form method="POST" action="/settings/add-user">
<div class="grid grid-cols-2 gap-4 max-w-2xl"> <div class="grid grid-cols-2 gap-4 max-w-2xl">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('auth.name') }}</label>
<input type="text" name="name" required class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> <input type="text" name="name" required class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('auth.email') }}</label>
<input type="email" name="email" required class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> <input type="email" name="email" required class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('auth.password') }}</label>
<input type="password" name="password" required minlength="6" class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> <input type="password" name="password" required minlength="6" class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Role</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ t('users.role') }}</label>
<select name="role" class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500"> <select name="role" class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
<option value="user">User</option> <option value="user">{{ t('users.role_user') }}</option>
<option value="admin">Administrator</option> <option value="admin">{{ t('users.role_admin') }}</option>
</select> </select>
</div> </div>
</div> </div>
<button type="submit" class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700"> <button type="submit" class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
<i class="fas fa-user-plus mr-2"></i>Add User <i class="fas fa-user-plus mr-2"></i>{{ t('users.add_user') }}
</button> </button>
</form> </form>
</div> </div>
@@ -230,17 +236,17 @@
<div class="bg-white shadow rounded-lg overflow-hidden"> <div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-5 border-b border-gray-200"> <div class="px-6 py-5 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900"> <h2 class="text-lg font-medium text-gray-900">
<i class="fas fa-users mr-2 text-purple-600"></i>All Users <i class="fas fa-users mr-2 text-purple-600"></i>{{ t('users.all_users') }}
</h2> </h2>
</div> </div>
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('auth.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('auth.email') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('users.role') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('users.created') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('settings.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@@ -255,17 +261,17 @@
<td class="px-6 py-4 text-sm text-gray-500">{{ u.email }}</td> <td class="px-6 py-4 text-sm text-gray-500">{{ u.email }}</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if u.role == 'admin' %} {% if u.role == 'admin' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">Admin</span> <span class="px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">{{ t('users.role_admin') }}</span>
{% else %} {% else %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">User</span> <span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">{{ t('users.role_user') }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">{{ u.created_at|date('Y-m-d') }}</td> <td class="px-6 py-4 text-sm text-gray-500">{{ u.created_at|date('Y-m-d') }}</td>
<td class="px-6 py-4 text-sm"> <td class="px-6 py-4 text-sm">
{% if u.id != user.id %} {% if u.id != user.id %}
<form method="POST" action="/settings/delete-user/{{ u.id }}" class="inline" onsubmit="return confirm('Delete {{ u.name }}?')"> <form method="POST" action="/settings/delete-user/{{ u.id }}" class="inline" onsubmit="return confirm('{{ t('users.delete_confirm', [u.name]) }}')">
<button type="submit" class="text-red-600 hover:text-red-900"> <button type="submit" class="text-red-600 hover:text-red-900">
<i class="fas fa-trash"></i> Delete <i class="fas fa-trash"></i> {{ t('clients.delete') }}
</button> </button>
</form> </form>
{% endif %} {% endif %}