Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc7bc514e1 | ||
|
|
4526301fd1 | ||
|
|
b3759139bf | ||
|
|
2d52ee6113 | ||
|
|
2208bb0ec7 | ||
|
|
8643f45370 |
+6
-9
@@ -1,35 +1,32 @@
|
|||||||
# /home/docker/pve_dashboard/Dockerfile
|
|
||||||
FROM php:8.2-apache
|
FROM php:8.2-apache
|
||||||
|
|
||||||
# Benötigte Pakete installieren (SQLite + OpenSSL für Zertifikate + Cron für den Scheduler)
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
openssl \
|
openssl \
|
||||||
cron \
|
cron \
|
||||||
&& docker-php-ext-install pdo_sqlite
|
&& docker-php-ext-install pdo_sqlite
|
||||||
|
|
||||||
# Apache Rewrite-Modul (für schöne URLs) und SSL-Modul aktivieren
|
|
||||||
RUN a2enmod rewrite ssl
|
RUN a2enmod rewrite ssl
|
||||||
|
|
||||||
# Generiere ein Self-Signed Zertifikat (Gültig für 10 Jahre)
|
|
||||||
RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||||
-keyout /etc/ssl/private/apache-selfsigned.key \
|
-keyout /etc/ssl/private/apache-selfsigned.key \
|
||||||
-out /etc/ssl/certs/apache-selfsigned.crt \
|
-out /etc/ssl/certs/apache-selfsigned.crt \
|
||||||
-subj "/C=DE/ST=Brandenburg/L=Gruenheide/O=PUC/OU=IT/CN=localhost"
|
-subj "/C=DE/ST=Brandenburg/L=Gruenheide/O=PUC/OU=IT/CN=localhost"
|
||||||
|
|
||||||
# Passe die Default SSL Konfiguration an
|
|
||||||
RUN sed -i 's/ssl-cert-snakeoil.pem/apache-selfsigned.crt/g' /etc/apache2/sites-available/default-ssl.conf \
|
RUN sed -i 's/ssl-cert-snakeoil.pem/apache-selfsigned.crt/g' /etc/apache2/sites-available/default-ssl.conf \
|
||||||
&& sed -i 's/ssl-cert-snakeoil.key/apache-selfsigned.key/g' /etc/apache2/sites-available/default-ssl.conf
|
&& sed -i 's/ssl-cert-snakeoil.key/apache-selfsigned.key/g' /etc/apache2/sites-available/default-ssl.conf
|
||||||
|
|
||||||
# SSL Site in Apache aktivieren
|
|
||||||
RUN a2ensite default-ssl.conf
|
RUN a2ensite default-ssl.conf
|
||||||
|
|
||||||
# Cronjob einrichten (Ruft die cron.php minütlich auf und leitet Output ins Docker-Log um)
|
COPY ./src /var/www/html/
|
||||||
|
RUN chown -R www-data:www-data /var/www/html/
|
||||||
|
|
||||||
|
# Der goldene Fix für die Datenbankrechte!
|
||||||
|
RUN mkdir -p /var/www/data && chown -R www-data:www-data /var/www/data
|
||||||
|
|
||||||
RUN echo "* * * * * root /usr/local/bin/php /var/www/html/cron.php > /proc/1/fd/1 2>/proc/1/fd/2\n" >> /etc/crontab
|
RUN echo "* * * * * root /usr/local/bin/php /var/www/html/cron.php > /proc/1/fd/1 2>/proc/1/fd/2\n" >> /etc/crontab
|
||||||
|
|
||||||
# Beide Ports freigeben
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
EXPOSE 443
|
EXPOSE 443
|
||||||
|
|
||||||
# Startbefehl: Startet erst den Cron-Daemon im Hintergrund und dann Apache im Vordergrund
|
|
||||||
CMD cron && apache2-foreground
|
CMD cron && apache2-foreground
|
||||||
@@ -38,32 +38,45 @@ Built entirely with native APIs—no slow iframes, no CORS issues.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Quickstart (Docker)
|
## 📦 Quickstart (Docker & Portainer)
|
||||||
|
|
||||||
Deploying the Proxmox Unified Console is incredibly easy using Docker.
|
Die Installation erfolgt am einfachsten über Docker Compose. Die Applikation bringt einen eigenen Apache-Webserver mit und generiert sich automatisch ein SSL-Zertifikat für HTTPS.
|
||||||
|
|
||||||
1. **Clone the repository:**
|
**1. `docker-compose.yml` anlegen:**
|
||||||
```bash
|
```yaml
|
||||||
git clone [https://github.com/tessmania90/proxmox-unified-console.git](https://github.com/tessmania90/proxmox-unified-console.git)
|
version: '3.8'
|
||||||
cd proxmox-unified-console
|
|
||||||
|
|
||||||
Start the container:
|
services:
|
||||||
Bash
|
pve-dashboard:
|
||||||
|
image: stessmann/proxmox-unified-console:latest
|
||||||
|
container_name: puc_dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
- "8443:443"
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/www/data
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Berlin
|
||||||
|
|
||||||
docker compose up -d --build
|
2. Starten:
|
||||||
|
Bash
|
||||||
|
|
||||||
Access the Dashboard:
|
docker compose up -d
|
||||||
Open your browser and navigate to:
|
|
||||||
https://<YOUR-SERVER-IP>:8443
|
|
||||||
(Note: You will need to accept the self-signed certificate warning).
|
|
||||||
|
|
||||||
Default Login:
|
3. ⚠️ WICHTIG: Rechte für SQLite anpassen:
|
||||||
|
Da der Container als Benutzer www-data (UID 33) läuft, der neu erstellte Volume-Ordner auf dem Host aber oft root gehört, muss dem Ordner die Schreibberechtigung erteilt werden. Führe im Verzeichnis der Compose-Datei aus:
|
||||||
|
Bash
|
||||||
|
|
||||||
Username: admin
|
sudo chown -R 33:33 ./data
|
||||||
|
sudo chmod -R 775 ./data
|
||||||
|
|
||||||
Password: admin
|
4. Login:
|
||||||
|
Rufe https://<DEINE-IP>:8443 in deinem Browser auf (Zertifikatswarnung ignorieren).
|
||||||
|
|
||||||
⚠️ IMPORTANT: Please change the default password immediately after your first login!
|
Benutzername: admin
|
||||||
|
|
||||||
|
Passwort: admin (Bitte direkt nach dem Einloggen oben rechts über das 🔑-Symbol ändern!)
|
||||||
|
|
||||||
🛠️ Architecture
|
🛠️ Architecture
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -1,20 +1,14 @@
|
|||||||
# /home/docker/pve_dashboard/docker-compose.yml
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pve-dashboard:
|
pve-dashboard:
|
||||||
build: .
|
image: stessmann/proxmox-unified-console:latest
|
||||||
container_name: pve_unified_console
|
container_name: puc_dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
# Port 80 bleibt fürs Testing / HAProxy
|
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
# NEU: Port 443 für sicheres NoVNC! (Wir mappen es nach außen z.B. auf 8443)
|
|
||||||
- "8443:443"
|
- "8443:443"
|
||||||
volumes:
|
volumes:
|
||||||
# Dein Source-Code
|
|
||||||
- ./src:/var/www/html
|
|
||||||
# Deine Datenbank, sicher ausgelagert
|
|
||||||
- ./data:/var/www/data
|
- ./data:/var/www/data
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
+136
-29
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
// /home/docker/pve_dashboard/src/api.php
|
// /home/docker/pve_dashboard/src/api.php
|
||||||
|
ini_set('display_errors', 0); // Verhindert, dass PHP-Warnungen das JSON zerstören
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
require_once 'db.php';
|
require_once 'db.php';
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
@@ -7,62 +10,152 @@ $action = $_GET['action'] ?? '';
|
|||||||
// === ZENTRALE AUDIT LOG FUNKTION ===
|
// === ZENTRALE AUDIT LOG FUNKTION ===
|
||||||
function logAudit($actionName, $target = '') {
|
function logAudit($actionName, $target = '') {
|
||||||
global $pdo;
|
global $pdo;
|
||||||
|
try {
|
||||||
@session_start();
|
@session_start();
|
||||||
$userId = $_SESSION['user_id'] ?? 0;
|
$userId = $_SESSION['user_id'] ?? 0;
|
||||||
$username = $_SESSION['username'] ?? 'System';
|
$username = $_SESSION['username'] ?? 'System';
|
||||||
session_write_close();
|
@session_write_close();
|
||||||
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action, target) VALUES (?, ?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action, target) VALUES (?, ?, ?, ?)");
|
||||||
$stmt->execute([$userId, $username, $actionName, $target]);
|
$stmt->execute([$userId, $username, $actionName, $target]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Audit Log Fehler: " . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'login') {
|
if ($action === 'login') {
|
||||||
$username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? '';
|
$username = trim($_POST['username'] ?? '');
|
||||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(PDO::FETCH_ASSOC);
|
$password = $_POST['password'] ?? '';
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if ($user && password_verify($password, $user['password_hash'])) {
|
if ($user && password_verify($password, $user['password_hash'])) {
|
||||||
$_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['role'] = $user['role'];
|
@session_start();
|
||||||
logAudit('User Login', 'IP: ' . $_SERVER['REMOTE_ADDR']);
|
$_SESSION['user_id'] = $user['id'];
|
||||||
|
$_SESSION['username'] = $user['username'];
|
||||||
|
$_SESSION['role'] = $user['role'];
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
|
||||||
|
logAudit('User Login', 'IP: ' . $ip);
|
||||||
echo json_encode(['success' => true]);
|
echo json_encode(['success' => true]);
|
||||||
} else echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']);
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']);
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'logout') {
|
if ($action === 'logout') {
|
||||||
logAudit('User Logout', '');
|
logAudit('User Logout', '');
|
||||||
session_destroy(); echo json_encode(['success' => true]); exit;
|
@session_start();
|
||||||
|
session_destroy();
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($_SESSION['user_id'])) { echo json_encode(['success' => false, 'error' => 'Zugriff verweigert.']); exit; }
|
@session_start();
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Zugriff verweigert.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === PASSWORT ÄNDERN ===
|
||||||
|
if ($action === 'change_password') {
|
||||||
|
$oldPass = $_POST['old_password'] ?? '';
|
||||||
|
$newPass = $_POST['new_password'] ?? '';
|
||||||
|
$userId = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT password_hash FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($user && password_verify($oldPass, $user['password_hash'])) {
|
||||||
|
if(strlen($newPass) < 4) { echo json_encode(['success' => false, 'error' => 'Passwort muss mindestens 4 Zeichen lang sein.']); exit; }
|
||||||
|
$newHash = password_hash($newPass, PASSWORD_DEFAULT);
|
||||||
|
$uStmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
|
||||||
|
$uStmt->execute([$newHash, $userId]);
|
||||||
|
logAudit('Passwort geändert', 'Self-Service');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Das alte Passwort ist falsch.']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$isAdmin = ($_SESSION['role'] ?? '') === 'admin';
|
$isAdmin = ($_SESSION['role'] ?? '') === 'admin';
|
||||||
session_write_close();
|
@session_write_close();
|
||||||
|
|
||||||
function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 4) {
|
// === PROXMOX API FETCHER (JETZT MIT TICKET-AUTH FÜR ALLE SYSTEME) ===
|
||||||
|
function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 6) {
|
||||||
$port = ($type === 'pbs') ? 8007 : 8006;
|
$port = ($type === 'pbs') ? 8007 : 8006;
|
||||||
if ($type === 'pmg' || $type === 'pbs') {
|
|
||||||
|
// 1. Ticket holen (Login)
|
||||||
$chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket");
|
$chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket");
|
||||||
curl_setopt_array($chAuth, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 2, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret])]);
|
curl_setopt_array($chAuth, [
|
||||||
$authRes = json_decode(curl_exec($chAuth), true); curl_close($chAuth);
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
if(!isset($authRes['data']['ticket'])) return ['data' => []];
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
$cookieName = ($type === 'pbs') ? 'PBSAuthCookie' : 'PMGAuthCookie';
|
CURLOPT_SSL_VERIFYHOST => false,
|
||||||
$headers = ["Cookie: {$cookieName}=" . $authRes['data']['ticket'], "CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken']];
|
CURLOPT_TIMEOUT => 4,
|
||||||
} else {
|
CURLOPT_POST => true,
|
||||||
$headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"];
|
CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret])
|
||||||
|
]);
|
||||||
|
$authRes = json_decode(curl_exec($chAuth), true);
|
||||||
|
curl_close($chAuth);
|
||||||
|
|
||||||
|
if(!isset($authRes['data']['ticket'])) {
|
||||||
|
return ['data' => null, 'error' => 'Authentifizierung fehlgeschlagen'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Passendes Cookie für das System wählen
|
||||||
|
$cookieName = 'PVEAuthCookie';
|
||||||
|
if ($type === 'pbs') $cookieName = 'PBSAuthCookie';
|
||||||
|
if ($type === 'pmg') $cookieName = 'PMGAuthCookie';
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
"Cookie: {$cookieName}=" . $authRes['data']['ticket'],
|
||||||
|
"CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken']
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Eigentlichen API-Call ausführen
|
||||||
$ch = curl_init("https://{$ip}:{$port}{$endpoint}");
|
$ch = curl_init("https://{$ip}:{$port}{$endpoint}");
|
||||||
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers];
|
$options = [
|
||||||
if ($method !== "GET") { $options[CURLOPT_CUSTOMREQUEST] = $method; if ($postData) $options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData; }
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => false,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_HTTPHEADER => $headers
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($method !== "GET") {
|
||||||
|
$options[CURLOPT_CUSTOMREQUEST] = $method;
|
||||||
|
if ($postData) {
|
||||||
|
$options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
curl_setopt_array($ch, $options);
|
curl_setopt_array($ch, $options);
|
||||||
$res = curl_exec($ch); curl_close($ch); return json_decode($res, true);
|
$res = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return json_decode($res, true) ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkVmPermission($pdo, $vmid) {
|
function checkVmPermission($pdo, $vmid) {
|
||||||
global $isAdmin; if ($isAdmin) return true;
|
global $isAdmin;
|
||||||
@session_start(); $userId = $_SESSION['user_id'] ?? 0; session_write_close();
|
if ($isAdmin) return true;
|
||||||
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?"); $stmt->execute([$userId]); return in_array($vmid, json_decode($stmt->fetchColumn(), true)['allowed_vms'] ?? []);
|
|
||||||
|
@session_start();
|
||||||
|
$userId = $_SESSION['user_id'] ?? 0;
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$perms = json_decode($stmt->fetchColumn() ?: '{}', true);
|
||||||
|
|
||||||
|
return in_array($vmid, $perms['allowed_vms'] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === AUDIT LOG ENDPUNKT ===
|
|
||||||
if ($action === 'get_audit_logs') {
|
if ($action === 'get_audit_logs') {
|
||||||
if (!$isAdmin) exit;
|
if (!$isAdmin) exit;
|
||||||
$stmt = $pdo->query("SELECT * FROM audit_logs ORDER BY id DESC LIMIT 200");
|
$stmt = $pdo->query("SELECT * FROM audit_logs ORDER BY id DESC LIMIT 200");
|
||||||
@@ -81,13 +174,22 @@ if ($action === 'get_recent_jobs') {
|
|||||||
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
|
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
|
||||||
} elseif ($node['type'] === 'pmg') {
|
} elseif ($node['type'] === 'pmg') {
|
||||||
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pmg');
|
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pmg');
|
||||||
if (isset($nData['data'][0]['node'])) { $tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nData['data'][0]['node']}/tasks?limit=10", 'pmg'); if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } }
|
if (isset($nData['data'][0]['node'])) {
|
||||||
|
$tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nData['data'][0]['node']}/tasks?limit=10", 'pmg');
|
||||||
|
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
|
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
|
||||||
if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { $tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/tasks?limit=10", 'pve'); if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } } }
|
if (isset($nData['data'])) {
|
||||||
|
foreach ($nData['data'] as $nInfo) {
|
||||||
|
$tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/tasks?limit=10", 'pve');
|
||||||
|
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usort($allTasks, function($a, $b) { return ($b['starttime'] ?? 0) <=> ($a['starttime'] ?? 0); }); echo json_encode(['success' => true, 'data' => array_slice($allTasks, 0, 15)]); exit;
|
}
|
||||||
|
}
|
||||||
|
usort($allTasks, function($a, $b) { return ($b['starttime'] ?? 0) <=> ($a['starttime'] ?? 0); });
|
||||||
|
echo json_encode(['success' => true, 'data' => array_slice($allTasks, 0, 15)]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'get_updates') {
|
if ($action === 'get_updates') {
|
||||||
@@ -98,7 +200,12 @@ if ($action === 'get_updates') {
|
|||||||
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $node['name'] . " (" . $c . ")"; }
|
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $node['name'] . " (" . $c . ")"; }
|
||||||
} else {
|
} else {
|
||||||
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
|
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
|
||||||
if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { $apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/apt/update", 'pve'); if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $nInfo['node'] . " (" . $c . ")"; } } }
|
if (isset($nData['data'])) {
|
||||||
|
foreach ($nData['data'] as $nInfo) {
|
||||||
|
$apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/apt/update", 'pve');
|
||||||
|
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $nInfo['node'] . " (" . $c . ")"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo json_encode(['success' => true, 'total' => $total, 'details' => implode(', ', $nodesNeed)]); exit;
|
echo json_encode(['success' => true, 'total' => $total, 'details' => implode(', ', $nodesNeed)]); exit;
|
||||||
|
|||||||
+34
-11
@@ -11,7 +11,10 @@ if (!window.APP.isLoggedIn) {
|
|||||||
if(setupForm) { setupForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Verbinde...'; const fd = new FormData(); fd.append('name', document.getElementById('nodeName').value); fd.append('ip', document.getElementById('nodeIp').value); fd.append('user', document.getElementById('nodeUser').value); fd.append('pass', document.getElementById('nodePass').value); fd.append('type', 'pve'); try { const res = await (await fetch('api.php?action=add_node', { method: 'POST', body: fd })).json(); if(res.success) { btn.innerText = 'Erfolgreich!'; setTimeout(() => window.location.reload(), 1000); } else { alert(res.error); btn.innerText = oTxt; } } catch (e) { alert('Netzwerkfehler.'); btn.innerText = oTxt; } }); }
|
if(setupForm) { setupForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Verbinde...'; const fd = new FormData(); fd.append('name', document.getElementById('nodeName').value); fd.append('ip', document.getElementById('nodeIp').value); fd.append('user', document.getElementById('nodeUser').value); fd.append('pass', document.getElementById('nodePass').value); fd.append('type', 'pve'); try { const res = await (await fetch('api.php?action=add_node', { method: 'POST', body: fd })).json(); if(res.success) { btn.innerText = 'Erfolgreich!'; setTimeout(() => window.location.reload(), 1000); } else { alert(res.error); btn.innerText = oTxt; } } catch (e) { alert('Netzwerkfehler.'); btn.innerText = oTxt; } }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() { await fetch('api.php?action=logout'); window.location.reload(); }
|
async function logout() {
|
||||||
|
await fetch('api.php?action=logout');
|
||||||
|
window.location.href = window.location.pathname + '?t=' + Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||||
|
|
||||||
@@ -24,6 +27,34 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === NEU: PASSWORT ÄNDERN LOGIK ===
|
||||||
|
const pwdModal = document.getElementById('passwordModal');
|
||||||
|
window.openPasswordModal = function() { if(pwdModal) { pwdModal.classList.remove('hidden'); document.getElementById('changePasswordForm').reset(); } }
|
||||||
|
window.closePasswordModal = function() { if(pwdModal) pwdModal.classList.add('hidden'); }
|
||||||
|
|
||||||
|
const pwdForm = document.getElementById('changePasswordForm');
|
||||||
|
if(pwdForm) {
|
||||||
|
pwdForm.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const oldP = document.getElementById('oldPassword').value;
|
||||||
|
const newP = document.getElementById('newPassword').value;
|
||||||
|
const confirmP = document.getElementById('newPasswordConfirm').value;
|
||||||
|
|
||||||
|
if (newP !== confirmP) { alert('Die neuen Passwörter stimmen nicht überein!'); return; }
|
||||||
|
|
||||||
|
const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Speichere...';
|
||||||
|
const fd = new FormData(); fd.append('old_password', oldP); fd.append('new_password', newP);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await (await fetch('api.php?action=change_password', {method: 'POST', body: fd})).json();
|
||||||
|
if(res.success) {
|
||||||
|
alert('Passwort erfolgreich geändert! Bitte neu anmelden.');
|
||||||
|
closePasswordModal(); logout();
|
||||||
|
} else { alert(res.error || 'Fehler beim Ändern des Passworts.'); }
|
||||||
|
} catch(err) { alert('Netzwerkfehler.'); } finally { btn.innerText = oTxt; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window.switchTab = function(tab) {
|
window.switchTab = function(tab) {
|
||||||
['pve', 'pbs', 'pmg'].forEach(t => { const el = document.getElementById('tab-' + t); const nav = document.getElementById('nav-tab-' + t); if (t === tab) { el.classList.remove('hidden'); setTimeout(() => el.classList.remove('opacity-0'), 50); nav.classList.add('tab-active' + (t === 'pve' ? '' : '-' + t)); } else { el.classList.add('hidden', 'opacity-0'); nav.classList.remove('tab-active', 'tab-active-pbs', 'tab-active-pmg'); } });
|
['pve', 'pbs', 'pmg'].forEach(t => { const el = document.getElementById('tab-' + t); const nav = document.getElementById('nav-tab-' + t); if (t === tab) { el.classList.remove('hidden'); setTimeout(() => el.classList.remove('opacity-0'), 50); nav.classList.add('tab-active' + (t === 'pve' ? '' : '-' + t)); } else { el.classList.add('hidden', 'opacity-0'); nav.classList.remove('tab-active', 'tab-active-pbs', 'tab-active-pmg'); } });
|
||||||
if(tab === 'pbs') fetchPbsStats(); if(tab === 'pmg') fetchPmgStats();
|
if(tab === 'pbs') fetchPbsStats(); if(tab === 'pmg') fetchPmgStats();
|
||||||
@@ -537,7 +568,7 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
|||||||
window.toggleCronJob = async function(id, newState) { const fd = new FormData(); fd.append('id', id); fd.append('is_active', newState); await fetch('api.php?action=toggle_cron_job', {method: 'POST', body: fd}); loadCronJobs(); }
|
window.toggleCronJob = async function(id, newState) { const fd = new FormData(); fd.append('id', id); fd.append('is_active', newState); await fetch('api.php?action=toggle_cron_job', {method: 'POST', body: fd}); loadCronJobs(); }
|
||||||
window.deleteCronJob = async function(id) { if(!confirm('Diesen geplanten Job wirklich löschen?')) return; const fd = new FormData(); fd.append('id', id); await fetch('api.php?action=delete_cron_job', {method: 'POST', body: fd}); loadCronJobs(); }
|
window.deleteCronJob = async function(id) { if(!confirm('Diesen geplanten Job wirklich löschen?')) return; const fd = new FormData(); fd.append('id', id); await fetch('api.php?action=delete_cron_job', {method: 'POST', body: fd}); loadCronJobs(); }
|
||||||
|
|
||||||
// === NEU: AUDIT LOG ===
|
// === AUDIT LOG ===
|
||||||
const auditModal = document.getElementById('auditLogModal');
|
const auditModal = document.getElementById('auditLogModal');
|
||||||
window.openAuditLog = async function() {
|
window.openAuditLog = async function() {
|
||||||
auditModal.classList.remove('hidden');
|
auditModal.classList.remove('hidden');
|
||||||
@@ -549,18 +580,10 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
|||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-500 py-4">Noch keine Einträge.</td></tr>'; return; }
|
if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-500 py-4">Noch keine Einträge.</td></tr>'; return; }
|
||||||
res.data.forEach(log => {
|
res.data.forEach(log => {
|
||||||
// Konvertiere SQLite DATETIME in lokales deutsches Format
|
|
||||||
const d = new Date(log.timestamp + 'Z');
|
const d = new Date(log.timestamp + 'Z');
|
||||||
const timeStr = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE');
|
const timeStr = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE');
|
||||||
const isSystem = log.username === 'System';
|
const isSystem = log.username === 'System';
|
||||||
|
tbody.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"><td class="px-4 py-3 text-gray-400 whitespace-nowrap text-xs">${timeStr}</td><td class="px-4 py-3"><span class="${isSystem ? 'text-gray-500' : 'text-blue-400 font-bold'}">${log.username}</span></td><td class="px-4 py-3 font-bold text-white">${log.action}</td><td class="px-4 py-3 text-gray-300 text-xs">${log.target || '-'}</td></tr>`;
|
||||||
tbody.innerHTML += `
|
|
||||||
<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50">
|
|
||||||
<td class="px-4 py-3 text-gray-400 whitespace-nowrap text-xs">${timeStr}</td>
|
|
||||||
<td class="px-4 py-3"><span class="${isSystem ? 'text-gray-500' : 'text-blue-400 font-bold'}">${log.username}</span></td>
|
|
||||||
<td class="px-4 py-3 font-bold text-white">${log.action}</td>
|
|
||||||
<td class="px-4 py-3 text-gray-300 text-xs">${log.target || '-'}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch(e) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-red-500 py-4">Fehler beim Laden.</td></tr>'; }
|
} catch(e) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-red-500 py-4">Fehler beim Laden.</td></tr>'; }
|
||||||
|
|||||||
+30
-8
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// /home/docker/pve_dashboard/src/db.php
|
||||||
session_start();
|
session_start();
|
||||||
try {
|
try {
|
||||||
// Hier ist der Fix: Der Pfad zeigt wieder auf das ausgelagerte /data Verzeichnis!
|
|
||||||
$pdo = new PDO('sqlite:' . __DIR__ . '/../data/dashboard.sqlite');
|
$pdo = new PDO('sqlite:' . __DIR__ . '/../data/dashboard.sqlite');
|
||||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// 1. User Tabelle
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS users (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
@@ -14,6 +15,7 @@ try {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)");
|
)");
|
||||||
|
|
||||||
|
// 2. Nodes Tabelle
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS nodes (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS nodes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -23,19 +25,39 @@ try {
|
|||||||
type TEXT DEFAULT 'pve'
|
type TEXT DEFAULT 'pve'
|
||||||
)");
|
)");
|
||||||
|
|
||||||
// Automatisches Datenbank-Upgrade für den PBS! (Fügt Spalte 'type' hinzu, falls sie fehlt)
|
// Automatisches Upgrade für ältere Versionen
|
||||||
try {
|
try { $pdo->exec("ALTER TABLE nodes ADD COLUMN type TEXT DEFAULT 'pve'"); } catch (PDOException $e) {}
|
||||||
$pdo->exec("ALTER TABLE nodes ADD COLUMN type TEXT DEFAULT 'pve'");
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
// Ignorieren, wenn die Spalte bereits existiert
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 3. NEU: Task Scheduler Tabelle
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
target_vmid TEXT,
|
||||||
|
cron_schedule TEXT NOT NULL,
|
||||||
|
last_run INTEGER DEFAULT 0,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)");
|
||||||
|
|
||||||
|
// 4. NEU: Audit Logs Tabelle
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username TEXT,
|
||||||
|
action TEXT,
|
||||||
|
target TEXT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)");
|
||||||
|
|
||||||
|
// Standard-Admin anlegen, falls noch keine User existieren
|
||||||
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
|
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
|
||||||
if ($stmt->fetchColumn() == 0) {
|
if ($stmt->fetchColumn() == 0) {
|
||||||
$hash = password_hash('admin', PASSWORD_DEFAULT);
|
$hash = password_hash('admin', PASSWORD_DEFAULT);
|
||||||
$pdo->exec("INSERT INTO users (username, password_hash, role) VALUES ('admin', '$hash', 'admin')");
|
$pdo->exec("INSERT INTO users (username, password_hash, role) VALUES ('admin', '$hash', 'admin')");
|
||||||
}
|
}
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
die("Datenbank-Fehler: " . $e->getMessage());
|
die(json_encode(['success' => false, 'error' => "Datenbank-Fehler: " . $e->getMessage()]));
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
+9
-2
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// /home/docker/pve_dashboard/src/index.php
|
||||||
require_once 'db.php';
|
require_once 'db.php';
|
||||||
$isLoggedIn = isset($_SESSION['user_id']);
|
$isLoggedIn = isset($_SESSION['user_id']);
|
||||||
$stmt = $pdo->query("SELECT COUNT(*) FROM nodes");
|
$stmt = $pdo->query("SELECT COUNT(*) FROM nodes");
|
||||||
@@ -37,6 +38,11 @@ $nodeCount = $stmt->fetchColumn();
|
|||||||
<?php if ($isLoggedIn): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<div class="flex items-center gap-4 text-sm">
|
||||||
<span class="text-gray-400">Hallo, <span class="text-white font-bold"><?= htmlspecialchars($_SESSION['username']) ?></span></span>
|
<span class="text-gray-400">Hallo, <span class="text-white font-bold"><?= htmlspecialchars($_SESSION['username']) ?></span></span>
|
||||||
|
|
||||||
|
<button onclick="openPasswordModal()" class="text-gray-400 hover:text-white transition-colors" title="Passwort ändern">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4v-3.286l5.742-5.742C9.4 11.135 9 10.126 9 9a6 6 0 0112 0z"></path></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button>
|
<button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -109,7 +115,6 @@ $nodeCount = $stmt->fetchColumn();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- FOOTER MIT TESSMANN.DEV -->
|
|
||||||
<footer class="bg-darkcard border-t border-darkborder py-4 mt-auto z-10 w-full">
|
<footer class="bg-darkcard border-t border-darkborder py-4 mt-auto z-10 w-full">
|
||||||
<div class="max-w-[1600px] w-full mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-2 text-xs text-gray-500">
|
<div class="max-w-[1600px] w-full mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-2 text-xs text-gray-500">
|
||||||
<div>
|
<div>
|
||||||
@@ -125,6 +130,8 @@ $nodeCount = $stmt->fetchColumn();
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script> window.APP = { isLoggedIn: <?= $isLoggedIn ? 'true' : 'false' ?>, nodeCount: <?= $nodeCount ?>, username: '<?= htmlspecialchars($_SESSION['username'] ?? '') ?>' }; </script>
|
<script> window.APP = { isLoggedIn: <?= $isLoggedIn ? 'true' : 'false' ?>, nodeCount: <?= $nodeCount ?>, username: '<?= htmlspecialchars($_SESSION['username'] ?? '') ?>' }; </script>
|
||||||
<script src="app.js"></script>
|
|
||||||
|
<!-- HIER IST DER CACHE-BUSTER, DAMIT DER LOGOUT IMMER GEHT! -->
|
||||||
|
<script src="app.js?v=<?= time() ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -95,3 +95,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- NEU: PASSWORT ÄNDERN MODAL -->
|
||||||
|
<div id="passwordModal" class="fixed inset-0 bg-black/80 hidden z-[100] flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col">
|
||||||
|
<div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg">
|
||||||
|
<h2 class="text-lg font-bold text-white flex items-center gap-2">🔑 Passwort ändern</h2>
|
||||||
|
<button onclick="closePasswordModal()" class="text-gray-400 hover:text-white transition-colors text-xl">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<form id="changePasswordForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Aktuelles Passwort</label>
|
||||||
|
<input type="password" id="oldPassword" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Neues Passwort</label>
|
||||||
|
<input type="password" id="newPassword" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Neues Passwort bestätigen</label>
|
||||||
|
<input type="password" id="newPasswordConfirm" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-2.5 rounded mt-2 transition-colors">Passwort aktualisieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user