diff --git a/Readme.md b/Readme.md index 7102102..dad67de 100644 --- a/Readme.md +++ b/Readme.md @@ -24,13 +24,20 @@ ## ✨ Features - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand +- 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout +- 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten - 👥 **Benutzerverwaltung** mit granularer Site-Zugriffskontrolle - 🔐 **Authentifizierung** via lokale Accounts **oder** Microsoft 365 OAuth +- 🔑 **Passwort-Reset** per E-Mail (token-basiert, zeitlich begrenzt) - 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle) +- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert +- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`) +- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay - 📊 **Admin-Dashboard** mit Live-Statistiken und Sync-Funktion +- 📝 **Audit-Log** – nachvollziehbare Protokollierung von Login & Änderungen (mit Filter) - 📥 **CSV-Export** aller Vouchers pro Site -- 🔄 **Integrierter Auto-Updater** – Updates per Klick aus dem Admin-Bereich +- 🔄 **Integrierter Auto-Updater** – Updates & DB-Migrationen per Klick aus dem Admin-Bereich - 🛡️ **Security-by-default**: CSRF-Schutz, bcrypt-Passwörter, Prepared Statements, Login-Rate-Limiting, OAuth-State-Validierung, Verschlüsselung sensibler Daten @@ -46,6 +53,13 @@ Voucher-Ergebnis +### Bulk-Erstellung & Dark Mode + +
+ Bulk-Voucher-Erstellung + Dashboard im Dark Mode +
+ ### Administration & Updater
@@ -129,7 +143,9 @@ SSH oder manuelles `git pull`. geschützten Pfaden (`config.php`, Uploads, …), automatischen DB-Migrationen und OPcache-Reset - 🔀 **Channel-Auswahl** zwischen `stable` und `development` -- 📊 **Migrations-Status** in einem eigenen Tab +- 📊 **Migrations-Status** in einem eigenen Tab – inkl. Button **„Ausstehende + Migrationen ausführen"** (legt z. B. neue Tabellen für bestehende + Installationen an, ohne dass ein Code-Update nötig ist) Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt: @@ -272,12 +288,15 @@ Body: {"cmd": "delete-voucher", "_id": ""} ## 🗺️ Roadmap -- [ ] Voucher-Templates (vordefinierte Laufzeiten) -- [ ] Bulk-Voucher-Erstellung +- [x] Voucher-Templates (vordefinierte Laufzeiten) +- [x] Bulk-Voucher-Erstellung +- [x] Mehrsprachigkeit (DE/EN) +- [x] Dark Mode +- [x] Passwort-Reset +- [x] Audit-Log +- [x] Auto-Updater mit DB-Migrationen - [ ] Erweiterte Reporting-Funktionen - [ ] Docker-Container -- [ ] Mehrsprachigkeit -- [x] Auto-Updater mit DB-Migrationen --- diff --git a/docs/screenshots/admin-dashboard-dark.png b/docs/screenshots/admin-dashboard-dark.png new file mode 100644 index 0000000..0e345f0 Binary files /dev/null and b/docs/screenshots/admin-dashboard-dark.png differ diff --git a/docs/screenshots/bulk-vouchers.png b/docs/screenshots/bulk-vouchers.png new file mode 100644 index 0000000..5a7d429 Binary files /dev/null and b/docs/screenshots/bulk-vouchers.png differ diff --git a/updater/UpdateController.php b/updater/UpdateController.php index 99d9e28..c7543df 100644 --- a/updater/UpdateController.php +++ b/updater/UpdateController.php @@ -34,6 +34,7 @@ class UpdateController case 'progress': $this->actionProgress(); break; case 'set_channel': $this->actionSetChannel(); break; case 'migrations': $this->actionMigrations(); break; + case 'run_migrations': $this->actionRunMigrations(); break; default: $this->renderPage(); } } @@ -99,6 +100,28 @@ class UpdateController } } + private function actionRunMigrations(): void + { + if (!$this->auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $this->json(['error' => 'Ungültiges Sicherheits-Token'], 403); + return; + } + try { + $runner = new MigrationRunner( + $this->db->getConnection(), + __DIR__ . '/migrations', + __DIR__ . '/storage' + ); + $applied = $runner->runPending(true); + if ($this->audit) { + $this->audit->log('migrations_run', ['applied' => $applied], $_SESSION['user_id'] ?? null); + } + $this->json(['success' => true, 'applied' => $applied, 'migrations' => $runner->status()]); + } catch (\Throwable $e) { + $this->json(['error' => $e->getMessage()], 500); + } + } + // ------------------------------------------------------------------- Render private function renderPage(): void diff --git a/updater/migrations/0001_feature_ui_tables.sql b/updater/migrations/0001_feature_ui_tables.sql new file mode 100644 index 0000000..47adb7b --- /dev/null +++ b/updater/migrations/0001_feature_ui_tables.sql @@ -0,0 +1,29 @@ +-- Updater-Migration: Tabellen der UI/Feature-Erweiterung fuer bestehende +-- Installationen nachziehen (Voucher-Templates + Password-Reset-Tokens). +-- Idempotent (CREATE TABLE IF NOT EXISTS) – auf frischen Installationen, die +-- database.sql bereits enthalten, ein No-Op. + +CREATE TABLE IF NOT EXISTS `voucher_templates` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `max_uses` INT NOT NULL DEFAULT 1, + `expire_minutes` INT NOT NULL DEFAULT 480, + `description` VARCHAR(500), + `is_active` TINYINT(1) DEFAULT 1, + `created_by` INT, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT NOT NULL, + `token` VARCHAR(128) NOT NULL, + `expires_at` TIMESTAMP NOT NULL, + `used` TINYINT(1) DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + UNIQUE KEY `unique_token` (`token`), + INDEX `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/updater/templates/update.php b/updater/templates/update.php index 725cb30..c6fdd9b 100644 --- a/updater/templates/update.php +++ b/updater/templates/update.php @@ -133,6 +133,8 @@ $channels = \Updater\UpdateManager::CHANNELS;

Migrations-Status

Wird geladen …

+ +
@@ -250,10 +252,36 @@ async function loadMigrations() { '
' + m.filename + '' + 'angewandt' : 'badge-off">offen') + '
' ).join(''); + const pending = d.migrations.some(m => !m.applied); + $('btnRunMig').style.display = pending ? 'inline-block' : 'none'; } catch (e) { el.innerHTML = '

Fehler: ' + e.message + '

'; } } + +$('btnRunMig').addEventListener('click', async () => { + $('btnRunMig').disabled = true; $('btnRunMig').textContent = 'Führe aus …'; + $('migAlert').classList.remove('show'); + try { + const body = new URLSearchParams({ action: 'run_migrations', csrf_token: CSRF }); + const r = await fetch('update.php', { method: 'POST', body }); + const d = await r.json(); + if (d.error) { + $('migAlert').textContent = 'Fehler: ' + d.error; + $('migAlert').className = 'alert alert-error show'; + } else { + const n = (d.applied || []).length; + $('migAlert').textContent = n > 0 ? (n + ' Migration(en) ausgeführt.') : 'Keine ausstehenden Migrationen.'; + $('migAlert').className = 'alert alert-ok show'; + loadMigrations(); + } + } catch (e) { + $('migAlert').textContent = 'Fehler: ' + e.message; + $('migAlert').className = 'alert alert-error show'; + } finally { + $('btnRunMig').disabled = false; $('btnRunMig').textContent = 'Ausstehende Migrationen ausführen'; + } +});