12 Commits
1.0.3 ... main

18 changed files with 4222 additions and 503 deletions

360
README.md
View File

@@ -1,17 +1,17 @@
# WP Business Forum Anwender-Dokumentation
# WP Business Forum - Anwender README
WP Business Forum bringt ein modernes, eigenständiges Community-Forum direkt in deine WordPress-Website.
Statt auf externe Plattformen auszuweichen, bleiben Diskussionen, Support-Anfragen und Mitgliederaktivität
zentral auf deiner eigenen Seite inklusive voller Kontrolle über Inhalte, Rollen und Moderation.
zentral auf deiner eigenen Seite - inklusive voller Kontrolle über Inhalte, Rollen und Moderation.
Diese Dokumentation richtet sich an Betreiber, Moderatoren und Community-Manager, die das Forum
schnell einrichten, sicher betreiben und im Alltag effizient verwalten möchten. Von der ersten
Installation bis zum Live-Betrieb findest du hier alle wichtigen Schritte und Funktionen kompakt erklärt.
---
Wenn du eine professionelle Community mit klaren Rechten, direkter Nutzerkommunikation und
strukturierter Moderation aufbauen willst, ist WP Business Forum dafür ausgelegt.
## Inhalt
1. Über das Plugin
2. Funktionsübersicht
3. Voraussetzungen
@@ -24,29 +24,19 @@ Installation bis zum Live-Betrieb findest du hier alle wichtigen Schritte und Fu
10. Export, Import und Deinstallation
11. FAQ / Troubleshooting
---
## 1) Über das Plugin
WP Business Forum ist ein eigenständiges Foren-System für WordPress mit:
- eigenem Forum-Login (unabhängig vom WP-Login)
- Rollen- und Rechteverwaltung
- Kategorien mit Hierarchie
- Moderationswerkzeugen
- Direktnachrichten, Benachrichtigungen, Meldesystem
- Umfragen, Tags, Reaktionen, Lesezeichen
- Level-System (beitragsbasierte Rangstufen)
- Vollständigem Export / Import mit automatischer ID-Zuordnung
Das Forum wird per Shortcode in eine WordPress-Seite eingebunden.
---
## 2) Funktionsübersicht
### Für Mitglieder
- Registrieren / Einloggen / Logout
- Passwort vergessen und Reset per E-Mail
- Threads erstellen, antworten, bearbeiten
@@ -58,339 +48,243 @@ Das Forum wird per Shortcode in eine WordPress-Seite eingebunden.
- Private Nachrichten (DM)
- Profil mit Avatar, Bio, Signatur und eigenen Profilfeldern
- Mitgliederliste und Suchfunktion
- Andere Nutzer ignorieren / blockieren
### Für Moderation / Admin
- Threads pinnen, schließen, archivieren, verschieben, löschen
- Beiträge löschen und wiederherstellen (Papierkorb)
- Beiträge löschen
- Meldungen (Reports) bearbeiten
- Kategorien und Rollen verwalten
- Mitglieder verwalten: Rolle ändern, Profil bearbeiten, Sperren, Löschen
- Einladungssystem für Registrierung
- Wartungsmodus
- Wortfilter / Zensurliste
- Statistiken und Aktivitäts-Dashboard
- Export / Import (vollständiges Backup mit Wortfilter, Ignore-Liste, Präfixen u. v. m.)
---
- Wortfilter
- Statistiken
- Papierkorb / Wiederherstellung
- Export / Import
## 3) Voraussetzungen
- Laufende WordPress-Installation (empfohlen: aktuelle Version)
- PHP 7.4 oder höher (empfohlen: PHP 8.0+)
- MySQL 5.7 / MariaDB 10.3 oder höher
- Schreibrechte für WordPress-Uploads (für Avatar- und Bild-Uploads)
- Laufende WordPress-Installation
- Schreibrechte für WordPress-Uploads (für Avatar-/Bild-Uploads)
- Funktionierende E-Mail-Zustellung in WordPress (für Passwort-Reset und Benachrichtigungen)
> Das Plugin nutzt eigene Datenbanktabellen mit dem Präfix `wp_forum_*` (bzw. deinem konfigurierten Tabellenpräfix).
---
Hinweis: Das Plugin nutzt eigene Datenbanktabellen (Präfix `wp_forum_*` bzw. mit deinem Tabellenpräfix).
## 4) Installation
1. Plugin-Ordner `wp-business-forum` in `wp-content/plugins/` kopieren.
2. Im WordPress-Backend unter **Plugins** aktivieren.
2. Im WordPress-Backend unter Plugins aktivieren.
3. Nach der Aktivierung startet einmalig der Setup-Wizard.
---
## 5) Ersteinrichtung (Setup-Wizard)
Nach Aktivierung führt der Wizard durch 3 Schritte:
Nach der Aktivierung führt der Wizard durch drei Schritte:
1. Superadmin-Konto erstellen oder bestehendes Forum-Konto verknüpfen
1. Superadmin-Konto erstellen oder bestehendes Forum-Konto hochstufen
2. Optional automatisch eine Forum-Seite erzeugen
3. Abschluss und Weiterleitung ins Dashboard
3. Abschluss
**Wichtig:**
- Der Superadmin ist fest mit dem WordPress-Administrator verknüpft und kann nicht über den Import überschrieben werden.
Wichtig:
- Der Superadmin ist fest mit dem WordPress-Admin verknüpft.
- Wenn noch kein Superadmin existiert, erscheint im Backend ein Hinweisbanner.
---
## 6) Forum-Seite einbinden
Das Forum wird mit folgendem Shortcode auf einer WordPress-Seite angezeigt:
```
```text
[business_forum]
```
**Empfehlung:**
- Eine eigene Seite (z. B. Forum") anlegen
Empfohlen:
- Eine eigene Seite (z. B. "Forum") anlegen
- Nur diesen Shortcode als Seiteninhalt verwenden
- Die Seite in der WordPress-Navigation verlinken
---
## 7) Bedienung im Frontend (Mitglieder)
### 7.1 Registrierung und Login
- Die Registrierung kann offen, nur per Einladung oder deaktiviert sein.
- Registrierung kann offen, nur per Einladung oder deaktiviert sein.
- Optional müssen Nutzer die Forum-Regeln akzeptieren.
- Spam-Schutz bei der Registrierung:
- Spam-Schutz bei Registrierung:
- Honeypot-Feld
- Mindestzeit bis zum Formular-Absenden
- Login unterstützt Angemeldet bleiben" (Remember-Me Cookie, 30 Tage).
- Mindestzeit bis Formular-Absenden
- Login unterstützt "Angemeldet bleiben" (Remember-Me Cookie).
### 7.2 Kategorien und Threads
- Kategorien können verschachtelt sein (Hauptkategorie + Unterkategorien).
- Die Sichtbarkeit kann rollenbasiert eingeschränkt werden.
- Threads können folgende Zustände haben: offen · geschlossen · archiviert · gepinnt
- Sichtbarkeit kann rollenbasiert sein.
- Threads können folgende Zustände haben:
- offen
- geschlossen
- archiviert
- gepinnt
### 7.3 Thread erstellen
- Mindestlänge Titel: 5 Zeichen
- Mindestlänge Inhalt: 10 Zeichen
- Mindestlänge Inhalt: 10 Zeichen (bei normalem Thread)
- Tags können vergeben werden
- Optional kann ein Thread-Präfix gesetzt werden
- Optional kann direkt eine Umfrage erstellt werden
### 7.4 Antworten und Bearbeiten
- Antworten mit BBCode-Unterstützung (`[b]`, `[i]`, `[quote]`, `[code]`, `[spoiler]`, `[url]`, `[img]` u. v. m.)
- Antworten mit BBCode-Unterstützung
- Flood-Control: konfigurierbare Wartezeit zwischen Posts
- Eigene Posts können nur innerhalb des konfigurierten Bearbeitungsfensters geändert werden
- Moderation kann unabhängig davon jederzeit eingreifen
- Eigene Posts nur innerhalb des eingestellten Bearbeitungsfensters (z. B. 30 Minuten)
- Moderation kann unabhängig davon eingreifen
### 7.5 Umfragen
- Umfrage direkt beim Thread-Erstellen oder nachträglich anfügen
- Umfrage direkt beim Thread-Erstellen oder nachträglich im Thread
- 2 bis 10 Antwortoptionen
- Optional Mehrfachauswahl
- Optional Enddatum
- Nach der Abstimmung werden Ergebnisse direkt angezeigt
- Nach Abstimmung werden Ergebnisse direkt angezeigt
### 7.6 Reaktionen, Likes, Lesezeichen
- Likes auf Threads und Beiträge
- Likes auf Thread/Beitrag
- Emoji-Reaktionen (adminseitig konfigurierbar)
- Lesezeichen für Threads, im Profil jederzeit einsehbar
- Lesezeichen für Threads (im Profil einsehbar)
### 7.7 Private Nachrichten (DM)
- 1:1 Nachrichten zwischen Mitgliedern
- Inbox-Ansicht und Konversationsansicht
- Ungelesene Nachrichten werden im Header gezählt
- Inbox-Ansicht und Konversation
- Ungelesene Nachrichten werden gezählt
- Optional E-Mail-Hinweis bei neuer Nachricht
### 7.8 Benachrichtigungen
Benachrichtigungen werden ausgelöst bei:
- Antworten auf abonnierte Threads
- @Erwähnungen in Beiträgen
- Neuen privaten Nachrichten
Benachrichtigungen bei:
- Antworten auf abonnierte / relevante Threads
- @Erwähnungen
- neuen privaten Nachrichten
### 7.9 Profil
Mitglieder können:
- Anzeigenamen, Bio und Signatur pflegen
- Avatar hochladen (max. 2 MB, JPG/PNG/GIF/WebP)
- Avatar hochladen
- Passwort ändern
- Profil-Sichtbarkeit umschalten
- Benutzerdefinierte Profilfelder ausfüllen (falls vom Admin aktiviert)
- Andere Nutzer zur Ignore-Liste hinzufügen
- eigene Profil-Sichtbarkeit umschalten
- benutzerdefinierte Profilfelder ausfüllen (falls aktiviert)
Upload-Limits:
- Avatar: max. 2 MB (JPG/PNG/GIF/WebP)
- Bild im Beitrag: max. 5 MB (JPG/PNG/GIF/WebP)
### 7.10 Passwort vergessen
Über „Passwort vergessen" kann ein Reset-Link per E-Mail angefordert werden. Das Zurücksetzen erfolgt über einen zeitlich begrenzten Token.
---
- Über "Passwort vergessen" kann ein Reset-Link per E-Mail angefordert werden.
- Das Zurücksetzen erfolgt über einen zeitlich gültigen Token.
## 8) Moderation und Verwaltung
Im WordPress-Backend gibt es den Menüpunkt "Business Forum" mit Unterseiten:
Im WordPress-Backend gibt es den Menüpunkt **Business Forum** mit folgenden Unterseiten:
| Unterseite | Funktion |
|---|---|
| Übersicht | Dashboard mit Kennzahlen, Trends und Aktivitätsprotokoll |
| Kategorien | Struktur, Hierarchie und Sichtbarkeit verwalten |
| Rollen | Rollen, Permissions und Design anpassen |
| Level | Beitragsbasierte Rangstufen konfigurieren |
| Mitglieder | Nutzer verwalten, sperren, löschen |
| Meldungen | Gemeldete Inhalte bearbeiten |
| Profilfelder | Eigene Felder definieren |
| Einstellungen | Texte, Sicherheit, Registrierung, Regeln, Wartung |
| Reaktionen | Erlaubte Emoji-Reaktionen konfigurieren |
| Einladungen | Invite-Codes erstellen und verwalten |
| Statistiken | Forum-Auswertung und Trends |
| Papierkorb | Gelöschte Inhalte einsehen und wiederherstellen |
| Thread-Präfixe | Farbige Label für Threads verwalten |
| Wortfilter | Unerwünschte Begriffe automatisch ersetzen |
| Export / Import | Vollständiges Backup und Wiederherstellung |
| ⚠️ Deinstallieren | Komplette Löschung inkl. aller Daten |
| 🔔 Updates | Update-Status und Changelog |
### 8.1 Mitglieder verwalten
In der Mitglieder-Übersicht stehen pro Nutzer drei Aktionen zur Verfügung:
**Rolle ändern**
Rolle direkt aus dem Dropdown wählen und speichern. Bei „Gesperrt" kann zusätzlich ein Sperrgrund und ein automatisches Ablaufdatum (temporäre Sperre) gesetzt werden.
**Profil bearbeiten**
Anzeigename, E-Mail, Passwort, Bio, Signatur und benutzerdefinierte Profilfelder direkt im Admin ändern.
**Nutzer löschen**
Beim Klick auf „Löschen" öffnet sich ein Bestätigungs-Panel mit zwei Optionen:
- **DSGVO Anonymisieren** *(empfohlen)*: Der Account wird nach Art. 17 DSGVO anonymisiert — Benutzername, E-Mail und Passwort werden gelöscht, Threads und Beiträge bleiben unter „Gelöschter Nutzer" erhalten.
- **Dauerhaft löschen**: Der Datensatz wird vollständig aus der Datenbank entfernt. Alle nutzerbezogenen Daten (Nachrichten, Likes, Reaktionen, Abonnements, Lesezeichen u. a.) werden gelöscht. Threads und Beiträge bleiben anonym erhalten. **Dieser Vorgang ist nicht rückgängig zu machen.**
> Der Superadmin-Account ist in beiden Pfaden geschützt und kann nicht gelöscht werden.
### 8.2 Sperren von Nutzern
Statt eines vollständigen Löschens kann ein Nutzer auch gesperrt werden (Rolle „Gesperrt"):
- **Permanente Sperre**: Kein Forum-Zugang, Sperrgrund wird beim Login angezeigt.
- **Temporäre Sperre**: Automatische Entsperrung zum angegebenen Datum/Uhrzeit. Bei Ablauf wird die vorherige Rolle automatisch wiederhergestellt.
---
- Übersicht: Dashboard mit Kennzahlen und Aktivitäten
- Kategorien: Struktur und Sichtbarkeit verwalten
- Rollen: Rollen/Permissions anpassen
- Level: Beitragsbasierte Rangstufen
- Mitglieder: Nutzer verwalten
- Meldungen: gemeldete Inhalte bearbeiten
- Profilfelder: eigene Felder definieren
- Einstellungen: Texte, Sicherheit, Registrierung, Regeln, Wartung
- Reaktionen: erlaubte Emoji-Reaktionen
- Einladungen: Invite-Codes erstellen und verwalten
- Statistiken: Forum-Auswertung
- Papierkorb: gelöschte Inhalte wiederherstellen
- Thread-Präfixe: Label für Threads verwalten
- Wortfilter: unerwünschte Begriffe ersetzen/filtern
- Export / Import: Backup und Wiederherstellung
- Deinstallieren: komplette Löschung des Plugins inkl. Daten
## 9) Einstellungen im Detail
Unter **Business Forum Einstellungen**:
Unter Business Forum > Einstellungen:
### 9.1 Texte und UI
- Hero-Titel und Untertitel
- Hero-Titel/Untertitel
- Topbar-Brand
- Labels für Statistiken
- Abschnittstitel und Buttontexte
- Label für Statistik
- Abschnittstitel
- Buttontexte
- Sidebar-Titel
### 9.2 Sicherheit
- Auto-Logout nach Inaktivität (0 = deaktiviert, in Minuten)
- Post-Bearbeitungslimit (in Minuten, 0 = unbegrenzt)
- Spam-Mindestzeit bei Registrierung (in Sekunden)
- Flood-Control Intervall zwischen Posts (in Sekunden, 0 = deaktiviert)
- Standard-Profil-Sichtbarkeit für neue Mitglieder
- Auto-Logout nach Inaktivität (0 = deaktiviert)
- Post-Bearbeitungslimit
- Spam-Mindestzeit bei Registrierung
- Flood-Control Intervall
- Profil-Sichtbarkeit (Standard)
### 9.3 Registrierung
- Modus: **offen** · **nur Einladung** · **deaktiviert**
- Freitext-Hinweis bei Einladungs-Modus
- Forum-Regeln bei Registrierung verpflichtend akzeptieren
- Modus:
- offen
- nur Einladung
- deaktiviert
- Freitext-Hinweis für Einladungsmode
### 9.4 Wartungsmodus
- Forum für normale Nutzer sperren
- Moderation und Admins behalten vollen Zugriff
- Eigener Wartungs-Titel und Hinweistext konfigurierbar
- Moderation/Admin behalten Zugriff
- Eigener Wartungs-Titel und Hinweistext
### 9.5 Forum-Regeln / Nutzungsbedingungen
- Regelseite aktivieren/deaktivieren
- Akzeptierung bei Registrierung optional verpflichtend
- Titel und Inhalt frei editierbar (unterstützt einfaches Markdown)
---
- Titel und Inhalt frei editierbar
## 10) Export, Import und Deinstallation
### 10.1 Export / Import
Exportierbare Bereiche (je nach Auswahl):
- Einstellungen
- Rollen und Level
- Kategorien
- Nutzer und User-Meta
- Threads und Posts
- Interaktionen (Likes/Reaktionen/Benachrichtigungen)
- Nachrichten
- Meldungen
- Einladungen
### 10.1 Export
Empfehlung:
- Vor großen Änderungen immer einen Voll-Export speichern.
Unter **Business Forum Export / Import** kannst du einzelne oder alle Bereiche als `.json`-Datei exportieren:
| Bereich | Enthält |
|---|---|
| Einstellungen & Wortfilter | Forum-Texte, Regeln, Labels, Auto-Logout, Wortfilter, Profilfeld-Definitionen, Reaktionen-Konfiguration |
| Rollen | Alle Rollen mit Berechtigungen und Design (Superadmin wird nie überschrieben) |
| Level-System | Level-Namen, Schwellenwerte, Icons, Farben, An/Aus-Status |
| Kategorien | Kategoriestruktur inkl. Eltern-Kind-Hierarchie, Icons, Min-Rolle |
| Benutzer & Profilfelder | Accounts inkl. Passwort-Hashes, Ban-Status, Profilfeld-Werte |
| Threads, Posts & Abonnements | Alle Inhalte inkl. Tag-Zuordnungen und Thread-Abonnements |
| Umfragen | Alle Umfragen inkl. Abstimmungen |
| Lesezeichen | Alle gespeicherten Thread-Lesezeichen |
| Thread-Präfixe | Alle Präfix-Labels, Farben und Reihenfolgen |
| Likes & Reaktionen | Likes, Emoji-Reaktionen, Benachrichtigungen |
| Privatnachrichten | Alle DM-Konversationen |
| Ignore-Liste | Alle gegenseitigen Nutzer-Blockierungen |
| Meldungen | Gemeldete Beiträge inkl. Status |
| Einladungen | Alle Einladungscodes inkl. Nutzungsanzahl und Ablaufdatum |
**Tipp:** Mit „Alle wählen" / „Keine" lässt sich die Auswahl schnell anpassen. Die Datei wird sofort heruntergeladen.
### 10.2 Import
Beim Import einer zuvor exportierten `.json`-Datei gilt:
- Maximale Dateigröße: **50 MB**
- Nur Dateien im WBF-Format werden akzeptiert
- **Benutzer-IDs werden beim Import automatisch gemappt** — Threads, Posts, Likes, Reaktionen und alle anderen nutzerbezogenen Daten werden korrekt auf die neuen Datenbank-IDs übertragen, auch wenn sich diese von der Quelldatenbank unterscheiden
- Nach dem Import werden alle Zähler (Beitrags-, Thread- und Reaktionszähler) automatisch neu berechnet
- Der Superadmin kann per Import nie überschrieben werden
Über die **Überschreiben-Optionen** lässt sich pro Bereich steuern, ob bestehende Daten ersetzt oder Duplikate übersprungen werden sollen.
> ⚠️ Erstelle vor jedem Import einen aktuellen Export als Sicherung. Benutzer-Exporte enthalten Passwort-Hashes — teile diese Dateien nicht öffentlich.
### 10.3 Deinstallation
Unter **Business Forum ⚠️ Deinstallieren** oder beim Löschen des Plugins im WordPress-Backend werden vollständig entfernt:
- Alle Forum-Datenbanktabellen (`wp_forum_*`)
- Alle Plugin-Optionen in `wp_options`
### 10.2 Deinstallation (wichtig)
Beim Löschen des Plugins werden komplett entfernt:
- alle Forum-Datenbanktabellen
- relevante Plugin-Optionen
- Transients
- Geplante Cron-Jobs
- Automatisch erstellte Forum-Seite
- Upload-Unterverzeichnis `wbf-avatars`
- geplanter Cron-Job
- automatisch erstellte Forum-Seite
- zugehörige Upload-Unterverzeichnisse
> **Das ist eine echte, unwiderrufliche Datenlöschung. Immer vorher einen vollständigen Export erstellen.**
---
Das ist eine echte Datenlöschung. Vorher immer Backup erstellen.
## 11) FAQ / Troubleshooting
### Login funktioniert nicht
- Prüfen, ob das Konto gesperrt ist
- Bei zeitlicher Sperre Ablaufzeit abwarten
- Bei Registrierung "Nur Einladung" gültigen Invite-Code nutzen
**Login funktioniert nicht**
Prüfen ob das Konto gesperrt ist. Bei temporärer Sperre das Ablaufdatum abwarten. Bei „Nur Einladung" einen gültigen Invite-Code verwenden.
### Registrierung nicht sichtbar
- In Einstellungen den Registrierungsmodus prüfen
- Bei deaktiviertem Modus ist keine Selbstregistrierung möglich
**Registrierung nicht sichtbar**
In den Einstellungen den Registrierungsmodus prüfen. Bei deaktiviertem Modus ist keine Selbstregistrierung möglich.
### Keine E-Mails kommen an
- WordPress-Mailversand prüfen (SMTP Plugin empfohlen)
- Admin-E-Mail in WordPress kontrollieren
**Keine E-Mails kommen an**
WordPress-Mailversand prüfen. Ein SMTP-Plugin wird empfohlen. Die Admin-E-Mail in WordPress kontrollieren.
### Upload von Bildern/Avatar scheitert
- Dateityp prüfen (nur JPG/PNG/GIF/WebP)
- Dateigröße prüfen (Avatar 2 MB, Beitrag 5 MB)
- Schreibrechte in Uploads prüfen
**Upload von Bildern / Avatar scheitert**
Dateityp prüfen (nur JPG/PNG/GIF/WebP). Dateigröße prüfen (Avatar 2 MB, Beitrag 5 MB). Schreibrechte im Uploads-Verzeichnis prüfen.
### Benutzer werden automatisch ausgeloggt
- Auto-Logout in den Forum-Einstellungen prüfen
**Import schlägt fehl oder überschreibt falsche Daten**
Sicherstellen, dass die Datei aus einer WBF-Installation stammt. Überschreiben-Optionen gezielt setzen. Bei sehr großen Backups `upload_max_filesize` und `post_max_size` in der `php.ini` erhöhen.
### Forum ist plötzlich "offline"
- Wartungsmodus in den Einstellungen deaktivieren
**Benutzer werden automatisch ausgeloggt**
Auto-Logout in den Forum-Einstellungen prüfen (Wert in Minuten, 0 = deaktiviert).
**Forum ist plötzlich „offline"**
Wartungsmodus in den Einstellungen deaktivieren.
**Suche liefert keine Ergebnisse**
Der Suchbegriff muss mindestens 2 Zeichen lang sein.
**Nach dem Import stimmen Beitragszähler nicht**
Ab Version 1.0.2 werden Zähler nach jedem Import automatisch neu berechnet. Bei älteren Imports einmalig einen neuen Import mit der aktuellen Version durchführen.
### Suche liefert keine Ergebnisse
- Suchbegriff muss mindestens 2 Zeichen haben
---
## Kurz-Checkliste für den Live-Betrieb
1. Setup-Wizard abschließen
2. Forum-Seite mit `[business_forum]` bereitstellen
3. Rollen und Kategorien final konfigurieren
4. Registrierungsmodus festlegen
5. Regeln/Nutzungsbedingungen hinterlegen
6. E-Mail-Versand testen
7. Vollständigen Backup-Export erstellen
7. Backup-Export erstellen
Viel Erfolg mit deinem Forum!

View File

@@ -30,6 +30,7 @@ add_action( 'admin_menu', function() {
add_submenu_page( 'wbf-admin', 'Thread-Präfixe','Thread-Präfixe','manage_options', 'wbf-prefixes', 'wbf_admin_prefixes' );
add_submenu_page( 'wbf-admin', 'Wortfilter', 'Wortfilter', 'manage_options', 'wbf-wordfilter', 'wbf_admin_wordfilter' );
add_submenu_page( 'wbf-admin', 'Export / Import','Export / Import','manage_options', 'wbf-export', 'wbf_admin_export' );
add_submenu_page( 'wbf-admin', '🎮 Discord', '🎮 Discord', 'manage_options', 'wbf-discord', 'wbf_admin_discord' );
add_submenu_page( 'wbf-admin', '⚠️ Deinstallieren', '⚠️ Deinstallieren', 'manage_options', 'wbf-uninstall', 'wbf_admin_uninstall' );
add_submenu_page( 'wbf-admin', '🔔 Updates', '🔔 Updates', 'manage_options', 'wbf-updates', 'wbf_admin_updates' );
}, 10 );
@@ -366,6 +367,11 @@ function wbf_admin_page() {
$existing = $wpdb->get_col("SHOW TABLES LIKE '{$wpdb->prefix}forum_%'");
$missing = array_filter($exp_tables, fn($t) => !in_array($wpdb->prefix.$t, $existing));
// ── MC Bridge StatusAPI ───────────────────────────────────────────────────
$mc_s = wbf_get_settings();
$mc_enabled = ! empty( $mc_s['mc_bridge_enabled'] );
$mc_api_url = trim( $mc_s['mc_bridge_api_url'] ?? '' );
// ── Trends ────────────────────────────────────────────────────────────────
$pt = (int)$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}forum_posts WHERE deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)");
$pl = (int)$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}forum_posts WHERE deleted_at IS NULL AND created_at BETWEEN DATE_SUB(NOW(), INTERVAL 14 DAY) AND DATE_SUB(NOW(), INTERVAL 7 DAY)");
@@ -481,6 +487,11 @@ function wbf_admin_page() {
</div>
<!-- ═══════════ SYSTEM BAR ════════════════════════════════════════════════ -->
<?php
// Plugin-Status prüfen
$gallery_active = class_exists('MC_Gallery_Core');
$shop_active = class_exists('WIS_DB');
?>
<div class="wbf-sysbar">
<span class="wbf-sysbar__label">System</span>
<span class="wbf-sbadge <?php echo $php_rec?'wbf-sbadge--ok':($php_ok?'wbf-sbadge--warn':'wbf-sbadge--err'); ?>">
@@ -493,11 +504,57 @@ function wbf_admin_page() {
<i class="fas fa-<?php echo $mail_ok?'envelope-circle-check':'xmark'; ?>"></i> wp_mail
</span>
<div class="wbf-sdivider"></div>
<span class="wbf-sbadge <?php echo $gallery_active?'wbf-sbadge--ok':'wbf-sbadge--err'; ?>">
<i class="fas fa-images"></i> Galerie: <?php echo $gallery_active?'Aktiv':'Nicht aktiv'; ?>
</span>
<span class="wbf-sbadge <?php echo $shop_active?'wbf-sbadge--ok':'wbf-sbadge--err'; ?>">
<i class="fas fa-shopping-cart"></i> Shop: <?php echo $shop_active?'Aktiv':'Nicht aktiv'; ?>
</span>
<div class="wbf-sdivider"></div>
<?php if (empty($missing)): ?>
<span class="wbf-sbadge wbf-sbadge--ok"><i class="fas fa-table-columns"></i> <?php echo count($exp_tables); ?> Tabellen OK</span>
<?php else: ?>
<span class="wbf-sbadge wbf-sbadge--err"><i class="fas fa-triangle-exclamation"></i> Fehlende Tabellen: <?php echo esc_html(implode(', ',$missing)); ?></span>
<?php endif; ?>
<div class="wbf-sdivider"></div>
<?php if ( ! $mc_enabled ) : ?>
<span class="wbf-sbadge" style="color:#94a3b8;border-color:#e2e8f0;background:#f8fafc" title="MC Bridge in den Einstellungen aktivieren">
<i class="fas fa-cubes"></i> MC Bridge: Aus
</span>
<?php elseif ( empty( $mc_api_url ) ) : ?>
<span class="wbf-sbadge wbf-sbadge--warn" title="Keine API-URL konfiguriert">
<i class="fas fa-cubes"></i> StatusAPI: Nicht konfiguriert
</span>
<?php else : ?>
<span id="wbf-mc-status-badge" class="wbf-sbadge wbf-sbadge--err" title="Verbindung wird geprüft...">
<i class="fas fa-spinner fa-spin"></i> StatusAPI: Prüfe...
</span>
<script>
(function() {
var badge = document.getElementById('wbf-mc-status-badge');
if (!badge) return;
var url = <?php echo json_encode( rest_url( 'mc-bridge/v1/status' ) ); ?>;
fetch(url, { cache: 'no-store' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d && d.success && d.enabled) {
badge.className = 'wbf-sbadge wbf-sbadge--ok';
badge.title = 'MC Bridge aktiv — Verbindung hergestellt';
badge.innerHTML = '<i class="fas fa-cubes"></i> StatusAPI: Verbunden';
} else {
badge.className = 'wbf-sbadge wbf-sbadge--err';
badge.title = 'MC Bridge deaktiviert oder Fehler';
badge.innerHTML = '<i class="fas fa-cubes"></i> StatusAPI: Nicht verbunden';
}
})
.catch(function() {
badge.className = 'wbf-sbadge wbf-sbadge--err';
badge.title = 'WordPress REST-Endpoint nicht erreichbar';
badge.innerHTML = '<i class="fas fa-cubes"></i> StatusAPI: Nicht verbunden';
});
})();
</script>
<?php endif; ?>
<span style="margin-left:auto;font-size:.7rem;color:#94a3b8"><?php echo $online_count; ?> gerade online</span>
</div>
@@ -1345,15 +1402,61 @@ function wbf_admin_members() {
}
}
}
// ── Admin: 2FA eines Users zurücksetzen ──────────────────────────────────
if ( isset( $_POST['wbf_admin_reset_2fa'] ) && check_admin_referer( 'wbf_admin_2fa_nonce' ) ) {
if ( current_user_can('manage_options') && class_exists('WBF_TOTP') ) {
$uid = (int) ( $_POST['user_id'] ?? 0 );
if ( $uid ) {
$target = WBF_DB::get_user( $uid );
if ( $target && $target->role !== WBF_Roles::SUPERADMIN ) {
WBF_TOTP::disable_for( $uid );
echo '<div class="notice notice-success is-dismissible"><p>2FA für <strong>'
. esc_html($target->display_name) . '</strong> zurückgesetzt.</p></div>';
}
}
}
}
$members = WBF_DB::get_all_users( 200 );
$s_discord = wbf_get_settings();
$dc_sync_on = ( $s_discord['discord_role_sync'] ?? '0' ) === '1' && trim( $s_discord['discord_bot_token'] ?? '' );
// Discord-Meta aller User vorladen (1 Query statt N)
$dc_meta = [];
if ( $dc_sync_on ) {
global $wpdb;
$rows = $wpdb->get_results(
"SELECT user_id,
MAX(CASE WHEN meta_key='discord_user_id' THEN meta_value END) AS discord_uid,
MAX(CASE WHEN meta_key='discord_username' THEN meta_value END) AS discord_name
FROM {$wpdb->prefix}forum_user_meta
WHERE meta_key IN ('discord_user_id','discord_username')
GROUP BY user_id"
);
foreach ( $rows as $r ) {
$dc_meta[ (int)$r->user_id ] = $r;
}
}
?>
<div class="wrap">
<h1 style="display:flex;align-items:center;justify-content:space-between">
<h1 style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
<span>Mitglieder</span>
<button type="button" class="button button-primary" onclick="document.getElementById('wbf-create-user-box').style.display=document.getElementById('wbf-create-user-box').style.display==='none'?'block':'none'">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?php if ( $dc_sync_on ): ?>
<button type="button" id="wbf-discord-sync-all-btn" class="button"
style="background:#5865f2;color:#fff;border-color:#4752c4;display:inline-flex;align-items:center;gap:5px"
title="Synchronisiert Discord-Rollen aller verknüpften Nutzer (Discord → Forum)">
<span class="dashicons dashicons-update" id="wbf-sync-icon" style="margin-top:3px"></span>
Discord-Rollen synchronisieren
</button>
<span id="wbf-discord-sync-result" style="font-weight:600;font-size:.85rem"></span>
<?php endif; ?>
<button type="button" class="button button-primary"
onclick="document.getElementById('wbf-create-user-box').style.display=
document.getElementById('wbf-create-user-box').style.display==='none'?'block':'none'">
+ Neuen Nutzer anlegen
</button>
</div>
</h1>
<!-- Neuen Nutzer anlegen -->
@@ -1409,6 +1512,7 @@ function wbf_admin_members() {
<th>#</th><th>Nutzer</th><th>E-Mail</th>
<th>Aktuelle Rolle</th><th>Beiträge</th>
<th>Registriert</th><th>Rolle ändern</th>
<?php if ( $dc_sync_on ): ?><th style="color:#5865f2"><i class="fab fa-discord"></i> Discord</th><?php endif; ?>
</tr>
</thead>
<tbody>
@@ -1417,9 +1521,17 @@ function wbf_admin_members() {
$color = esc_attr( $role['color'] );
$bg = esc_attr( $role['bg_color'] );
$icon = esc_attr( $role['icon'] ?? 'fas fa-user' );
$is_sa = ( $m->role === WBF_Roles::SUPERADMIN );
// Nur sperren wenn dieser Forum-User wirklich dem WP-Superadmin (ID 1) entspricht.
// Reine Rollen-Prüfung reicht nicht — sonst kann man versehentlich
// zugewiesene superadmin-Rollen nicht mehr korrigieren.
$wp_sa_data = get_userdata( WBF_Roles::get_wp_superadmin_id() );
$is_sa = ( $m->role === WBF_Roles::SUPERADMIN )
&& $wp_sa_data
&& ( strtolower($m->email) === strtolower($wp_sa_data->user_email) );
$ban_reason = esc_attr( $m->ban_reason ?? '' );
$opts = '';
$dc_user = $dc_meta[ (int)$m->id ] ?? null;
$has_dc = $dc_sync_on && $dc_user && ! empty( $dc_user->discord_uid );
foreach ( $roles as $k => $r ) {
if ( $k === WBF_Roles::SUPERADMIN ) continue;
$sel = $m->role === $k ? ' selected' : '';
@@ -1438,13 +1550,13 @@ function wbf_admin_members() {
<span class="wbf-role-preview" style="color:<?php echo $color; ?>;background:<?php echo $bg; ?>;border-color:<?php echo $color; ?>">
<i class="<?php echo $icon; ?>"></i> <?php echo esc_html( $role['label'] ); ?>
</span>
<?php if ( $is_sa ) : ?><em style="color:#999;font-size:.8em">(WP-Admin)</em><?php endif; ?>
<?php if ( $is_sa ) : ?><em style="color:#999;font-size:.8em">(Haupt-Admin)</em><?php endif; ?>
</td>
<td><?php echo esc_html( $m->post_count ); ?></td>
<td><?php echo esc_html( date( 'd.m.Y', strtotime( $m->registered ) ) ); ?></td>
<td>
<?php if ( $is_sa ) : ?>
<em style="color:#999">Automatisch (WP-Admin)</em>
<em style="color:#999">Gesperrt — Haupt-Superadmin (WP User ID <?php echo (int) WBF_Roles::get_wp_superadmin_id(); ?>)</em>
<?php else : ?>
<form method="post" style="display:flex;flex-direction:column;gap:5px">
<?php wp_nonce_field( 'wbf_member_role_nonce' ); ?>
@@ -1504,6 +1616,30 @@ function wbf_admin_members() {
</div>
</form>
<!-- 2FA Admin-Panel -->
<?php if ( class_exists('WBF_TOTP') && ! $is_sa ) : ?>
<div id="wbf-2fa-admin-<?php echo (int)$m->id; ?>"
style="margin-top:6px;padding:8px 10px;background:#fefce8;border:1px solid #fde68a;border-radius:4px;font-size:12px">
<strong style="color:#92400e"><i class="fas fa-shield-halved"></i> 2FA-Status:</strong>
<?php if ( WBF_TOTP::is_enabled_for($m->id) ) : ?>
<span style="color:#16a34a;font-weight:600"> ✔ Aktiv</span>
<form method="post" style="display:inline;margin-left:10px">
<?php wp_nonce_field( 'wbf_admin_2fa_nonce' ); ?>
<input type="hidden" name="user_id" value="<?php echo (int)$m->id; ?>">
<button type="submit" name="wbf_admin_reset_2fa"
class="button button-small"
style="color:#dc2626;border-color:rgba(220,38,38,.4);font-size:11px"
onclick="return confirm('2FA für <?php echo esc_js($m->display_name); ?> zurücksetzen?')">
<span class="dashicons dashicons-unlock" style="font-size:13px;width:13px;height:13px;vertical-align:-2px"></span>
2FA zurücksetzen
</button>
</form>
<?php else : ?>
<span style="color:#9ca3af"> — Nicht aktiv</span>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Inline Profil-Editor -->
<div id="wbf-edit-user-<?php echo (int)$m->id; ?>" style="display:none;margin-top:8px;padding:12px;background:#f9f9f9;border:1px solid #ddd;border-radius:4px;max-width:480px">
<strong style="font-size:13px">Profil bearbeiten: <?php echo esc_html($m->display_name); ?></strong>
@@ -1711,11 +1847,127 @@ function wbf_admin_members() {
</div>
<?php endif; ?>
</td>
<?php if ( $dc_sync_on ) : ?>
<td style="white-space:nowrap;min-width:140px;vertical-align:top;padding-top:8px">
<?php if ( $has_dc ) : ?>
<div style="display:flex;flex-direction:column;gap:5px">
<span style="font-size:.8rem;color:#5865f2;font-weight:600">
<i class="fab fa-discord"></i>
<?php echo esc_html( $dc_user->discord_name ?: $dc_user->discord_uid ); ?>
</span>
<button type="button"
class="button button-small wbf-dc-sync-user"
data-uid="<?php echo (int)$m->id; ?>"
data-nonce="<?php echo wp_create_nonce('wbf_nonce'); ?>"
style="color:#5865f2;border-color:#5865f2;font-size:.75rem;height:24px;line-height:22px">
<span class="dashicons dashicons-update" style="font-size:12px;width:12px;height:12px;margin-top:5px"></span>
Sync
</button>
<span class="wbf-dc-user-result" style="font-size:.75rem;font-weight:600"></span>
</div>
<?php else : ?>
<span style="font-size:.78rem;color:#9ca3af">Nicht verknüpft</span>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ( $dc_sync_on ) : ?>
<style>
@keyframes wbf-spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
.wbf-spinning { animation: wbf-spin .8s linear infinite; display:inline-block; }
</style>
<script>
(function(){
var nonce = '<?php echo wp_create_nonce("wbf_nonce"); ?>';
// ── Bulk-Sync ──────────────────────────────────────────────────────────
var allBtn = document.getElementById('wbf-discord-sync-all-btn');
var allRes = document.getElementById('wbf-discord-sync-result');
var allIcon = document.getElementById('wbf-sync-icon');
if (allBtn) {
allBtn.addEventListener('click', function() {
allBtn.disabled = true;
if (allIcon) allIcon.classList.add('wbf-spinning');
allRes.style.color = '#374151';
allRes.textContent = '⏳ Sync läuft…';
fetch(ajaxurl, {
method : 'POST',
headers : {'Content-Type':'application/x-www-form-urlencoded'},
body : 'action=wbf_manual_discord_sync&nonce=' + nonce
})
.then(function(r){ return r.json(); })
.then(function(d) {
allBtn.disabled = false;
if (allIcon) allIcon.classList.remove('wbf-spinning');
if (d.success) {
allRes.style.color = '#16a34a';
allRes.textContent = '✅ ' + (d.data.message || 'Fertig!');
// Seite neu laden damit neue Rollen sichtbar werden
setTimeout(function(){ location.reload(); }, 1800);
} else {
allRes.style.color = '#dc2626';
allRes.textContent = '❌ ' + ((d.data && d.data.message) || 'Fehler');
}
})
.catch(function() {
allBtn.disabled = false;
if (allIcon) allIcon.classList.remove('wbf-spinning');
allRes.style.color = '#dc2626';
allRes.textContent = '❌ Netzwerkfehler';
});
});
}
// ── Pro-Nutzer-Sync ───────────────────────────────────────────────────
document.querySelectorAll('.wbf-dc-sync-user').forEach(function(btn) {
btn.addEventListener('click', function() {
var uid = btn.dataset.uid;
var icon = btn.querySelector('.dashicons');
var result = btn.closest('div').querySelector('.wbf-dc-user-result');
btn.disabled = true;
if (icon) icon.classList.add('wbf-spinning');
if (result) { result.style.color='#374151'; result.textContent='⏳'; }
fetch(ajaxurl, {
method : 'POST',
headers : {'Content-Type':'application/x-www-form-urlencoded'},
body : 'action=wbf_discord_sync_user&nonce=' + nonce + '&user_id=' + uid
})
.then(function(r){ return r.json(); })
.then(function(d) {
btn.disabled = false;
if (icon) icon.classList.remove('wbf-spinning');
if (d.success) {
if (result) { result.style.color='#16a34a'; result.textContent='✅ OK'; }
// Rollenbadge in dieser Zeile nach 1s aktualisieren (Seitenreload)
setTimeout(function(){ location.reload(); }, 1200);
} else {
if (result) {
result.style.color = '#dc2626';
result.textContent = '❌ ' + ((d.data && d.data.message) || 'Fehler');
}
}
})
.catch(function() {
btn.disabled = false;
if (icon) icon.classList.remove('wbf-spinning');
if (result) { result.style.color='#dc2626'; result.textContent='❌ Netzwerkfehler'; }
});
});
});
})();
</script>
<?php endif; ?>
<?php
}
@@ -3191,6 +3443,8 @@ function wbf_admin_profile_fields() {
<!-- ── Felder je Kategorie ───────────────────────────────── -->
<?php
// Globaler Feld-Index — synchronisiert Checkboxen mit den sequentiellen []Arrays
$wbf_fidx = 0;
// Alle Kategorien + "Ohne Kategorie" am Ende ausgeben
$all_sections = $cats;
if ( isset($by_cat['__none__']) ) {
@@ -3234,7 +3488,8 @@ function wbf_admin_profile_fields() {
<?php
endif;
foreach ( $c_fields as $i_f => $f ):
$fi = 'fi_' . $f['key'];
$fi = $wbf_fidx;
$wbf_fidx++;
?>
<tr class="wbf-field-row" style="background:#fff">
<td style="padding:6px 8px">
@@ -3268,9 +3523,11 @@ function wbf_admin_profile_fields() {
<span class="wbf-options-placeholder" style="color:#ccc;font-size:.75rem;<?php echo ($f['type']??'text')==='select'?'display:none':''; ?>">—</span>
</td>
<td style="text-align:center;padding:6px 8px">
<input type="hidden" name="field_required[<?php echo $fi; ?>]" value="0">
<input type="checkbox" name="field_required[<?php echo $fi; ?>]" value="1" <?php checked($f['required']??0,1); ?>>
</td>
<td style="text-align:center;padding:6px 8px">
<input type="hidden" name="field_public[<?php echo $fi; ?>]" value="0">
<input type="checkbox" name="field_public[<?php echo $fi; ?>]" value="1" <?php checked($f['public']??1,1); ?>>
</td>
<td style="padding:6px 8px">
@@ -3318,7 +3575,7 @@ function wbf_admin_profile_fields() {
</div>
<script>
var wbfRowCount = <?php echo count($fields) + 100; ?>;
var wbfRowCount = <?php echo $wbf_fidx; ?>;
function wbfRemoveRow(btn) {
var tr = btn.closest('tr');
@@ -3359,8 +3616,8 @@ function wbf_admin_profile_fields() {
'<textarea name="field_options[]" rows="2" placeholder="Option 1\nOption 2" style="width:100%;font-size:.78rem;display:none" class="wbf-options-field"></textarea>' +
'<span class="wbf-options-placeholder" style="color:#ccc;font-size:.75rem">—</span>' +
'</td>' +
'<td style="text-align:center;padding:6px 8px"><input type="checkbox" name="field_required[new_' + i + ']" value="1"></td>' +
'<td style="text-align:center;padding:6px 8px"><input type="checkbox" name="field_public[new_' + i + ']" value="1" checked></td>' +
'<td style="text-align:center;padding:6px 8px"><input type="hidden" name="field_required[' + i + ']" value="0"><input type="checkbox" name="field_required[' + i + ']" value="1"></td>' +
'<td style="text-align:center;padding:6px 8px"><input type="hidden" name="field_public[' + i + ']" value="0"><input type="checkbox" name="field_public[' + i + ']" value="1" checked></td>' +
'<td style="padding:6px 8px"><select name="field_category[]" style="width:100%;font-size:.82rem">' + catOpts + '</select></td>' +
'<td style="padding:6px 8px"><button type="button" class="button" onclick="wbfRemoveRow(this)" style="color:#dc2626;border-color:#fca5a5;padding:2px 7px">✕</button></td>';
tbody.appendChild(tr);
@@ -3838,3 +4095,231 @@ function wbf_admin_wordfilter() {
</div>
<?php
}
// ── Discord-Bot-Verbindungstest (Admin AJAX) ──────────────────────────────────
add_action('wp_ajax_wbf_discord_test', function() {
if ( ! current_user_can('manage_options') ) wp_send_json_error(['message' => 'Keine Berechtigung.']);
check_ajax_referer('wbf_discord_test', 'nonce');
$s = wbf_get_settings();
$token = trim($s['discord_bot_token'] ?? '');
$guild = trim($s['discord_guild_id'] ?? '');
if ( ! $token ) {
wp_send_json_error(['message' => 'Kein Bot-Token gespeichert.']);
}
// Bot-Info abrufen (@me)
$res = wp_remote_get('https://discord.com/api/v10/users/@me', [
'timeout' => 8,
'headers' => [
'Authorization' => 'Bot ' . $token,
'Content-Type' => 'application/json',
],
]);
if ( is_wp_error($res) ) {
wp_send_json_error(['message' => 'HTTP-Fehler: ' . $res->get_error_message()]);
}
$code = wp_remote_retrieve_response_code($res);
$body = json_decode(wp_remote_retrieve_body($res), true);
if ( $code !== 200 || empty($body['id']) ) {
$err = $body['message'] ?? 'Unbekannter Fehler (HTTP ' . $code . ')';
wp_send_json_error(['message' => 'Discord API: ' . $err]);
}
$bot_name = ($body['username'] ?? 'Unbekannt') . '#' . ($body['discriminator'] ?? '0');
// Guild-Prüfung falls Guild-ID angegeben
$guild_info = '';
if ( $guild ) {
$gr = wp_remote_get("https://discord.com/api/v10/guilds/{$guild}", [
'timeout' => 6,
'headers' => ['Authorization' => 'Bot ' . $token],
]);
if ( ! is_wp_error($gr) && wp_remote_retrieve_response_code($gr) === 200 ) {
$gd = json_decode(wp_remote_retrieve_body($gr), true);
$guild_info = ' | Server: ' . ($gd['name'] ?? $guild);
} else {
$guild_info = ' | ⚠️ Server nicht gefunden oder Bot kein Mitglied';
}
}
wp_send_json_success(['message' => 'Bot: ' . $bot_name . $guild_info]);
});
// ── Discord-Cron: Rollen synchronisieren ──────────────────────────────────────
add_action('wbf_discord_role_sync', 'wbf_run_discord_role_sync');
if ( ! wp_next_scheduled('wbf_discord_role_sync') ) {
wp_schedule_event(time(), 'hourly', 'wbf_discord_role_sync');
}
function wbf_run_discord_role_sync() {
$s = wbf_get_settings();
if ( ($s['discord_role_sync'] ?? '0') !== '1' ) return;
$token = trim($s['discord_bot_token'] ?? '');
$guild = trim($s['discord_guild_id'] ?? '');
$role_map = json_decode($s['discord_role_map'] ?? '{}', true) ?: [];
if ( ! $token || ! $guild || empty($role_map) ) return;
global $wpdb;
// Alle verifizierten Discord-User holen (discord_user_id in user_meta gesetzt)
$rows = $wpdb->get_results(
"SELECT um.user_id, um.meta_value AS discord_user_id
FROM {$wpdb->prefix}forum_user_meta um
WHERE um.meta_key = 'discord_user_id' AND um.meta_value != ''"
);
foreach ( $rows as $row ) {
wbf_sync_discord_role_for_user((int)$row->user_id, $row->discord_user_id, $token, $guild, $role_map);
}
}
/**
* Synchronisiert die Discord-Serverrolle eines einzelnen Nutzers mit der Forum-Rolle.
*/
function wbf_sync_discord_role_for_user($forum_user_id, $discord_user_id, $token, $guild, $role_map) {
// Guild-Member-Info abrufen
$res = wp_remote_get("https://discord.com/api/v10/guilds/{$guild}/members/{$discord_user_id}", [
'timeout' => 6,
'headers' => ['Authorization' => 'Bot ' . $token],
]);
if ( is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 200 ) return;
$member = json_decode(wp_remote_retrieve_body($res), true);
$user_roles = $member['roles'] ?? [];
// Rollen-Map prüfen — erster Treffer gewinnt (Reihenfolge = Priorität)
foreach ( $role_map as $dc_role_id => $forum_role ) {
if ( in_array((string)$dc_role_id, array_map('strval', $user_roles), true) ) {
$forum_user = WBF_DB::get_user($forum_user_id);
if ( $forum_user && $forum_user->role !== 'superadmin' && $forum_user->role !== $forum_role ) {
WBF_DB::update_user($forum_user_id, ['role' => $forum_role]);
}
return;
}
}
}
// ── Discord-Admin-Seite ───────────────────────────────────────────────────────
if ( ! function_exists('wbf_admin_discord') ) {
function wbf_admin_discord() {
if ( ! current_user_can('manage_options') ) return;
$s = wbf_get_settings();
?>
<div class="wrap">
<h1>🎮 Discord-Integration</h1>
<p>Konfiguriere den Discord-Bot und die Rollen-Synchronisation.
Einstellungen werden in <a href="admin.php?page=wbf-settings">Einstellungen → Discord-Integration</a> gespeichert.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:900px;margin-top:1.5rem">
<!-- Status-Panel -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:20px">
<h3 style="margin-top:0">🔌 Bot-Status</h3>
<?php
$token = trim($s['discord_bot_token'] ?? '');
$guild = trim($s['discord_guild_id'] ?? '');
if (!$token): ?>
<p style="color:#dc2626"><i class="dashicons dashicons-warning"></i> Kein Bot-Token konfiguriert.</p>
<a href="admin.php?page=wbf-settings#discord" class="button button-primary">Jetzt einrichten</a>
<?php else: ?>
<p style="color:#16a34a;font-weight:600">✅ Bot-Token gespeichert</p>
<p style="color:<?php echo $guild ? '#16a34a' : '#f59e0b'; ?>">
<?php echo $guild ? '✅ Guild-ID: <code>' . esc_html($guild) . '</code>' : '⚠️ Keine Guild-ID gesetzt'; ?>
</p>
<p style="color:<?php echo ($s['discord_role_sync']??'0')==='1' ? '#16a34a' : '#9ca3af'; ?>">
Rollen-Sync: <strong><?php echo ($s['discord_role_sync']??'0')==='1' ? 'Aktiv' : 'Deaktiviert'; ?></strong>
</p>
<button type="button" class="button button-secondary" id="wbf-discord-test-btn2">
🔌 Verbindung testen
</button>
<span id="wbf-discord-test-result2" style="margin-left:10px;font-weight:600"></span>
<script>
document.getElementById('wbf-discord-test-btn2').addEventListener('click', function(){
var btn = this, res = document.getElementById('wbf-discord-test-result2');
btn.disabled = true; res.textContent = '⏳ Teste…';
fetch(ajaxurl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'action=wbf_discord_test&nonce=<?php echo wp_create_nonce("wbf_discord_test"); ?>'
}).then(r=>r.json()).then(function(d){
res.style.color = d.success ? '#16a34a' : '#dc2626';
res.textContent = d.success ? '✅ '+(d.data.message||'OK') : '❌ '+((d.data&&d.data.message)||'Fehler');
btn.disabled = false;
}).catch(function(){ res.style.color='#dc2626'; res.textContent='❌ Netzwerkfehler'; btn.disabled=false; });
});
</script>
<?php endif; ?>
</div>
<!-- Rollen-Map-Übersicht -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:20px">
<h3 style="margin-top:0">🔗 Aktive Rollen-Zuordnungen</h3>
<?php
$role_map = json_decode($s['discord_role_map'] ?? '{}', true) ?: [];
$all_roles = WBF_Roles::get_all();
if (empty($role_map)): ?>
<p style="color:#9ca3af">Keine Zuordnungen konfiguriert.</p>
<a href="admin.php?page=wbf-settings" class="button">Jetzt einrichten</a>
<?php else: ?>
<table class="widefat striped" style="font-size:.85rem">
<thead><tr><th>Discord Rollen-ID</th><th>Forum-Rolle</th></tr></thead>
<tbody>
<?php foreach ($role_map as $dc_id => $fr_key):
$fr_label = $all_roles[$fr_key]['label'] ?? $fr_key; ?>
<tr>
<td><code><?php echo esc_html($dc_id); ?></code></td>
<td><?php echo WBF_Roles::badge($fr_key); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<a href="admin.php?page=wbf-settings" class="button" style="margin-top:.75rem">Bearbeiten</a>
<?php endif; ?>
</div>
</div>
<!-- Verknüpfte Discord-Nutzer -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:20px;max-width:900px;margin-top:20px">
<h3 style="margin-top:0">👥 Verknüpfte Forum-Nutzer</h3>
<?php
global $wpdb;
$linked = $wpdb->get_results(
"SELECT fu.id, fu.username, fu.display_name, fu.role,
MAX(CASE WHEN um.meta_key='discord_username' THEN um.meta_value END) AS discord_name,
MAX(CASE WHEN um.meta_key='discord_user_id' THEN um.meta_value END) AS discord_uid
FROM {$wpdb->prefix}forum_users fu
JOIN {$wpdb->prefix}forum_user_meta um ON um.user_id = fu.id
WHERE um.meta_key IN ('discord_username','discord_user_id')
GROUP BY fu.id
HAVING discord_name != '' AND discord_name IS NOT NULL
ORDER BY fu.username"
);
if (empty($linked)): ?>
<p style="color:#9ca3af">Noch keine verknüpften Nutzer.</p>
<?php else: ?>
<table class="widefat striped" style="font-size:.85rem">
<thead><tr><th>Forum-Nutzer</th><th>Rolle</th><th>Discord-Name</th><th>Discord-ID</th></tr></thead>
<tbody>
<?php foreach ($linked as $u): ?>
<tr>
<td><strong><?php echo esc_html($u->display_name); ?></strong>
<span style="color:#9ca3af"> @<?php echo esc_html($u->username); ?></span></td>
<td><?php echo WBF_Roles::badge($u->role); ?></td>
<td><i class="fab fa-discord" style="color:#5865f2"></i> <?php echo esc_html($u->discord_name ?: ''); ?></td>
<td><code style="font-size:.78rem"><?php echo esc_html($u->discord_uid ?: ''); ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php
}
}

View File

@@ -0,0 +1,89 @@
<?php
/**
* MC Bridge — Einstellungs-Sektion für den Forum-Admin
*
* Diesen Block in forum-settings.php im Admin-Formular einfügen,
* z.B. nach der Discord-Sektion.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
?>
<!-- ═══ Minecraft Bridge ═══ -->
<div class="wbf-settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="font-size:1.3em">⛏️</span> Minecraft Bridge
</h3>
<p class="description" style="margin-bottom:16px;color:#9ca3af">
Verbindet das Forum mit deinem Minecraft-Server (BungeeCord StatusAPI Plugin).
Spieler können ihren Forum-Account verknüpfen und erhalten Ingame-Benachrichtigungen
bei neuen Antworten, Erwähnungen und Privatnachrichten.
</p>
<table class="form-table">
<tr>
<th>Aktiviert</th>
<td>
<label>
<input type="checkbox" name="wbf_settings[mc_bridge_enabled]" value="1"
<?php checked( ! empty( $s['mc_bridge_enabled'] ) ); ?>>
MC Bridge aktivieren
</label>
</td>
</tr>
<tr>
<th>StatusAPI URL</th>
<td>
<input type="url" name="wbf_settings[mc_bridge_api_url]"
value="<?php echo esc_attr( $s['mc_bridge_api_url'] ?? '' ); ?>"
class="regular-text"
placeholder="http://192.168.1.100:9191">
<p class="description">
Die URL deines BungeeCord StatusAPI Servers (IP + Port).
Beispiel: <code>http://dein-server:9191</code>
</p>
</td>
</tr>
<tr>
<th>API Secret</th>
<td>
<input type="password" name="wbf_settings[mc_bridge_api_secret]"
value="<?php echo esc_attr( $s['mc_bridge_api_secret'] ?? '' ); ?>"
class="regular-text"
autocomplete="new-password">
<p class="description">
Gemeinsames Passwort für die API-Kommunikation.
Muss identisch sein mit <code>forum.api_secret</code> in der
<code>verify.properties</code> des StatusAPI Plugins.
</p>
</td>
</tr>
<tr>
<th>Verbindungstest</th>
<td>
<button type="button" id="wbf-mc-test-btn" class="button"
onclick="wbfTestMcConnection()">
🔌 Verbindung testen
</button>
<span id="wbf-mc-test-result" style="margin-left:10px"></span>
<script>
function wbfTestMcConnection() {
var btn = document.getElementById('wbf-mc-test-btn');
var result = document.getElementById('wbf-mc-test-result');
var url = document.querySelector('input[name="wbf_settings[mc_bridge_api_url]"]').value;
if (!url) { result.textContent = '❌ Bitte erst eine URL eingeben.'; return; }
btn.disabled = true;
result.textContent = '⏳ Teste...';
fetch(url.replace(/\/$/, '') + '/forum/status')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) result.innerHTML = '✅ <strong>Verbunden!</strong> StatusAPI v' + (d.version || '?');
else result.textContent = '⚠️ Erreichbar aber Fehler: ' + JSON.stringify(d);
})
.catch(function(e) { result.textContent = '❌ Nicht erreichbar: ' + e.message; })
.finally(function() { btn.disabled = false; });
}
</script>
</td>
</tr>
</table>
</div>

View File

@@ -54,12 +54,24 @@ if ( ! function_exists('wbf_get_settings') ) {
'rules_content' => "**1. Respektvoller Umgang**\nBehandle alle Mitglieder freundlich und respektvoll. Beleidigungen, Mobbing und Diskriminierung sind nicht toleriert.\n\n**2. Keine Spam-Inhalte**\nWerbung, Spam und irrelevante Links sind verboten.\n\n**3. Keine illegalen Inhalte**\nJegliche Inhalte, die gegen geltendes Recht verstoßen, sind streng verboten.\n\n**4. Themenrelevanz**\nBeiträge sollten zur jeweiligen Kategorie passen.\n\n**5. Urheberrecht**\nVeröffentliche keine Inhalte, an denen du keine Rechte besitzt.\n\n**6. Datenschutz**\nTeile keine persönlichen Daten anderer Personen ohne deren Zustimmung.\n\n**7. Moderations-Entscheidungen**\nEntscheidungen der Moderatoren sind zu respektieren. Bei Fragen wende dich direkt ans Team.\n\nVerstöße können zur Verwarnung oder dauerhaften Sperrung führen.",
// Ignore/Block-System: Rollen die nicht geblockt werden können (kommagetrennte Schlüssel)
'ignore_blocked_roles' => 'superadmin,admin,moderator',
// Discord-Integration
'discord_bot_token' => '',
'discord_guild_id' => '',
'discord_client_id' => '',
'discord_client_secret' => '',
'discord_role_sync' => '0', // Rollen-Sync aktiviert?
'discord_role_map' => '', // JSON: {"discord_role_id":"forum_role_key"}
// Minecraft Bridge
'mc_bridge_enabled' => '0',
'mc_bridge_api_url' => '',
'mc_bridge_api_secret' => '',
];
$saved = get_option( 'wbf_settings', [] );
// Fehlende Keys mit Defaults auffüllen, leere Strings ignorieren
return array_merge( $defaults, array_filter( (array) $saved, 'strlen' ) );
// Keine Filterung mehr, damit auch bewusst geleerte Felder gespeichert werden
return array_merge( $defaults, (array) $saved );
}
}
@@ -130,9 +142,34 @@ function wbf_admin_settings() {
// rules_content separat (nicht in $fields, da textarea mit eigener Behandlung)
$settings['rules_content'] = sanitize_textarea_field( $_POST['rules_content'] ?? '' );
// Discord-Einstellungen gesondert speichern (sensitiv — niemals in wbf_settings öffentlich)
$discord_fields = ['discord_bot_token', 'discord_guild_id', 'discord_client_id', 'discord_client_secret'];
foreach ( $discord_fields as $df ) {
$settings[$df] = sanitize_text_field( $_POST[$df] ?? '' );
}
$settings['discord_role_sync'] = isset($_POST['discord_role_sync']) && $_POST['discord_role_sync'] === '1' ? '1' : '0';
// Discord-Rollen-Map: Array von discord_role_id => forum_role_key
$role_map = [];
$dc_ids = array_map('sanitize_text_field', (array)($_POST['discord_role_id'] ?? []));
$fr_keys = array_map('sanitize_key', (array)($_POST['discord_forum_role'] ?? []));
$valid_roles = array_keys(WBF_Roles::get_all());
foreach ( $dc_ids as $i => $dc_id ) {
$dc_id = trim($dc_id);
$fr_key = $fr_keys[$i] ?? '';
if ( $dc_id !== '' && in_array($fr_key, $valid_roles, true) ) {
$role_map[$dc_id] = $fr_key;
}
}
$settings['discord_role_map'] = json_encode($role_map);
// ── Minecraft Bridge ──────────────────────────────────────────────────
$settings['mc_bridge_api_url'] = esc_url_raw( trim( $_POST['mc_bridge_api_url'] ?? '' ) );
$settings['mc_bridge_api_secret'] = sanitize_text_field( $_POST['mc_bridge_api_secret'] ?? '' );
// Checkbox-Felder explizit als '0' speichern wenn nicht angehakt,
// damit array_filter(...,'strlen') sie nicht wegwirft und der Default '1' greift.
$checkbox_fields = ['maintenance_mode', 'rules_enabled', 'rules_accept_required'];
$checkbox_fields = ['maintenance_mode', 'rules_enabled', 'rules_accept_required', 'mc_bridge_enabled'];
foreach ( $checkbox_fields as $cb ) {
$settings[$cb] = isset($_POST[$cb]) && $_POST[$cb] === '1' ? '1' : '0';
}
@@ -150,6 +187,12 @@ function wbf_admin_settings() {
$settings['ignore_blocked_roles'] = implode( ',', $checked_roles );
update_option( 'wbf_settings', $settings );
// Superadmin WP-User-ID separat speichern (außerhalb von wbf_settings)
$sa_wp_id = (int) ( $_POST['superadmin_wp_id'] ?? 1 );
if ( $sa_wp_id < 1 ) $sa_wp_id = 1;
update_option( 'wbf_superadmin_wp_id', $sa_wp_id );
echo '<div class="notice notice-success is-dismissible"><p>✅ Einstellungen gespeichert!</p></div>';
}
@@ -251,6 +294,40 @@ function wbf_admin_settings() {
🔒 Sicherheit
</h2>
<table class="form-table" role="presentation">
<!-- ── Superadmin WP-User-ID ─────────────────── -->
<tr>
<th scope="row">
<label for="wbf_superadmin_wp_id">Superadmin WordPress-User-ID</label>
</th>
<td>
<?php
$sa_id = (int) get_option( 'wbf_superadmin_wp_id', 1 );
$sa_wpuser = get_userdata( $sa_id );
?>
<input type="number" id="wbf_superadmin_wp_id" name="superadmin_wp_id"
value="<?php echo $sa_id; ?>"
min="1" step="1"
style="width:80px">
<?php if ( $sa_wpuser ) : ?>
<span style="margin-left:10px;color:#16a34a;font-weight:600">
✅ <?php echo esc_html( $sa_wpuser->display_name ); ?>
&lt;<?php echo esc_html( $sa_wpuser->user_email ); ?>&gt;
</span>
<?php else : ?>
<span style="margin-left:10px;color:#dc2626;font-weight:600">
⚠️ Kein WordPress-User mit dieser ID gefunden!
</span>
<?php endif; ?>
<p class="description">
Nur dieser WordPress-User erhält automatisch die Forum-Rolle <strong>Superadmin</strong>
und kann sie nicht verlieren. Alle anderen WordPress-Admins können normale Forum-Rollen
haben und im Mitglieder-Bereich frei zugewiesen werden.<br>
<em>Standard: 1 (erster bei der WP-Installation angelegter User)</em>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="wbf_auto_logout_minutes">Auto-Logout nach Inaktivität</label>
@@ -499,6 +576,280 @@ function wbf_admin_settings() {
</tr>
</table>
<!-- ══════════════════════════════════════════════════════════
DISCORD-INTEGRATION
══════════════════════════════════════════════════════════ -->
<h2 style="border-bottom:1px solid #ddd;padding-bottom:.4rem;margin-top:2rem">
<span style="color:#5865f2">🎮</span> Discord-Integration
</h2>
<p class="description" style="margin-bottom:1rem">
Bot-Token und Guild-ID findest du im <a href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</a>.
Der Bot muss Mitglied deines Servers sein und die Berechtigung <strong>Direct Messages lesen/senden</strong> sowie
<strong>Server-Mitglieder verwalten</strong> besitzen.
</p>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="wbf_discord_bot_token">Bot-Token</label></th>
<td>
<input type="password" id="wbf_discord_bot_token" name="discord_bot_token"
value="<?php echo esc_attr($s['discord_bot_token']); ?>"
class="regular-text" autocomplete="off" placeholder="Bot-Token aus dem Developer Portal">
<p class="description">Niemals öffentlich teilen! Wird verschlüsselt in der Datenbank gespeichert.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="wbf_discord_guild_id">Server-ID (Guild ID)</label></th>
<td>
<input type="text" id="wbf_discord_guild_id" name="discord_guild_id"
value="<?php echo esc_attr($s['discord_guild_id']); ?>"
class="regular-text" placeholder="z. B. 123456789012345678">
<p class="description">Rechtsklick auf deinen Server → ID kopieren (Entwicklermodus muss aktiv sein).</p>
</td>
</tr>
<tr>
<th scope="row"><label for="wbf_discord_client_id">Client ID (optional)</label></th>
<td>
<input type="text" id="wbf_discord_client_id" name="discord_client_id"
value="<?php echo esc_attr($s['discord_client_id']); ?>"
class="regular-text" placeholder="Application ID">
<p class="description">Für zukünftige OAuth2-Unterstützung. Aktuell optional.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="wbf_discord_client_secret">Client Secret (optional)</label></th>
<td>
<input type="password" id="wbf_discord_client_secret" name="discord_client_secret"
value="<?php echo esc_attr($s['discord_client_secret']); ?>"
class="regular-text" autocomplete="off" placeholder="Client Secret">
</td>
</tr>
<tr>
<th scope="row">Rollen-Sync aktivieren</th>
<td>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" name="discord_role_sync" value="1"
<?php checked('1', $s['discord_role_sync'] ?? '0'); ?>>
Discord-Serverrollen automatisch auf Forum-Rollen mappen
</label>
<p class="description">
Wenn aktiviert, wird bei jedem Login und stündlich per Cron die Discord-Rolle des Nutzers
geprüft und die Forum-Rolle entsprechend der unten definierten Zuordnung aktualisiert.
</p>
</td>
</tr>
</table>
<!-- Discord Rollen-Map -->
<h3 style="margin-top:1.5rem">🔗 Discord-Rollen → Forum-Rollen Zuordnung</h3>
<p class="description" style="margin-bottom:.75rem">
Trage die Discord-Rollen-ID und die gewünschte Forum-Rolle ein.
Mehrere Einträge werden der Reihe nach geprüft — der erste Treffer gewinnt.
</p>
<?php
$role_map_raw = $s['discord_role_map'] ?? '{}';
$role_map = json_decode($role_map_raw, true) ?: [];
$forum_roles = WBF_Roles::get_sorted();
// Sicherstellen dass mindestens eine leere Zeile zum Hinzufügen da ist
if ( empty($role_map) ) $role_map[''] = '';
?>
<table class="widefat" id="wbf-discord-role-map" style="max-width:680px;margin-bottom:.75rem">
<thead><tr>
<th style="width:50%">Discord Rollen-ID</th>
<th style="width:40%">Forum-Rolle</th>
<th style="width:10%"></th>
</tr></thead>
<tbody>
<?php foreach ( $role_map as $dc_id => $fr_key ) : ?>
<tr class="wbf-role-map-row">
<td><input type="text" name="discord_role_id[]"
value="<?php echo esc_attr($dc_id); ?>"
placeholder="Discord Rollen-ID"
class="widefat" style="font-family:monospace"></td>
<td>
<select name="discord_forum_role[]" class="widefat">
<option value="">— wählen —</option>
<?php foreach ( $forum_roles as $rk => $role ) :
if ( $rk === 'superadmin' ) continue; ?>
<option value="<?php echo esc_attr($rk); ?>"
<?php selected($rk, $fr_key); ?>>
<?php echo esc_html($role['label']); ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td><button type="button" class="button button-small wbf-rm-role-row"
style="color:#c00">✕</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<button type="button" class="button" id="wbf-add-role-row">+ Zeile hinzufügen</button>
<script>
(function(){
document.getElementById('wbf-add-role-row').addEventListener('click', function(){
var tbody = document.querySelector('#wbf-discord-role-map tbody');
var row = document.querySelector('.wbf-role-map-row').cloneNode(true);
row.querySelectorAll('input').forEach(function(i){i.value='';});
row.querySelectorAll('select').forEach(function(s){s.selectedIndex=0;});
tbody.appendChild(row);
});
document.addEventListener('click', function(e){
if (e.target.classList.contains('wbf-rm-role-row')) {
var rows = document.querySelectorAll('.wbf-role-map-row');
if (rows.length > 1) e.target.closest('tr').remove();
}
});
})();
</script>
<!-- Test-Verbindung -->
<div style="margin-top:1.25rem;padding:1rem;background:#f0f7ff;border:1px solid #c3dafe;border-radius:6px;max-width:680px">
<strong>🔌 Verbindungstest</strong><br>
<p style="margin:.4rem 0 .75rem;color:#374151;font-size:.9rem">
Speichere zuerst die Einstellungen, dann klicke „Testen" um zu prüfen ob der Bot erreichbar ist.
</p>
<button type="button" class="button button-secondary" id="wbf-discord-test-btn">
🔌 Discord-Verbindung testen
</button>
<span id="wbf-discord-test-result" style="margin-left:10px;font-weight:600"></span>
</div>
<script>
document.getElementById('wbf-discord-test-btn').addEventListener('click', function(){
var btn = this;
var res = document.getElementById('wbf-discord-test-result');
btn.disabled = true;
res.textContent = '⏳ Teste…';
fetch(ajaxurl, {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
body: 'action=wbf_discord_test&nonce=<?php echo wp_create_nonce("wbf_discord_test"); ?>'
})
.then(r => r.json())
.then(function(d){
if (d.success) {
res.style.color = '#16a34a';
res.textContent = '✅ ' + (d.data.message || 'Verbunden!');
} else {
res.style.color = '#dc2626';
res.textContent = '❌ ' + ((d.data && d.data.message) || 'Fehler');
}
btn.disabled = false;
})
.catch(function(){ res.style.color='#dc2626'; res.textContent='❌ Netzwerkfehler'; btn.disabled=false; });
});
</script>
<!-- ══════════════════════════════════════════════════════════
Minecraft Bridge
════════════════════════════════════════════════════════════ -->
<div class="wbf-settings-box" style="margin-top:2rem;padding:1.5rem;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb">
<h2 style="margin-top:0;display:flex;align-items:center;gap:8px">
<span style="font-size:1.3em">⛏️</span> Minecraft Bridge
</h2>
<p class="description" style="margin-bottom:1.2rem;color:#6b7280">
Verbindet das Forum mit deinem BungeeCord-Server (StatusAPI Plugin).
Spieler können ihren Forum-Account mit <code>/forumlink &lt;token&gt;</code> verknüpfen
und erhalten dann Ingame-Benachrichtigungen bei neuen Antworten, Erwähnungen und PNs.
</p>
<table class="form-table" role="presentation">
<tr>
<th scope="row">Aktiviert</th>
<td>
<label>
<input type="checkbox" name="mc_bridge_enabled" value="1"
<?php checked( '1', $s['mc_bridge_enabled'] ?? '0' ); ?>>
Minecraft Bridge aktivieren
</label>
</td>
</tr>
<tr>
<th scope="row"><label for="wbf_mc_api_url">StatusAPI URL</label></th>
<td>
<input type="url" id="wbf_mc_api_url" name="mc_bridge_api_url"
value="<?php echo esc_attr( $s['mc_bridge_api_url'] ?? '' ); ?>"
class="regular-text"
placeholder="http://dein-server:9191">
<p class="description">
IP + Port deines BungeeCord StatusAPI Servers.
Beispiel: <code>http://192.168.1.100:9191</code>
</p>
</td>
</tr>
<tr>
<th scope="row"><label for="wbf_mc_api_secret">API Secret</label></th>
<td>
<input type="password" id="wbf_mc_api_secret" name="mc_bridge_api_secret"
value="<?php echo esc_attr( $s['mc_bridge_api_secret'] ?? '' ); ?>"
class="regular-text"
autocomplete="new-password"
placeholder="Gemeinsames Passwort">
<p class="description">
Muss identisch sein mit <code>forum.api_secret</code> in der
<code>verify.properties</code> des StatusAPI Plugins.
</p>
</td>
</tr>
<tr>
<th scope="row">Verbindungstest</th>
<td>
<button type="button" id="wbf-mc-test-btn" class="button"
onclick="wbfTestMcConnection()">
🔌 Verbindung testen
</button>
<span id="wbf-mc-test-result" style="margin-left:10px;font-weight:600"></span>
<script>
function wbfTestMcConnection() {
var btn = document.getElementById('wbf-mc-test-btn');
var result = document.getElementById('wbf-mc-test-result');
var url = document.getElementById('wbf_mc_api_url').value.replace(/\/$/, '');
if (!url) { result.style.color='#dc2626'; result.textContent = '❌ Bitte erst eine URL eingeben.'; return; }
btn.disabled = true;
result.style.color = '#6b7280';
result.textContent = '⏳ Teste Verbindung...';
// Test gegen WordPress REST-Endpoint (sicherer als direkter BungeeCord-Aufruf vom Browser)
fetch('<?php echo esc_url( rest_url("mc-bridge/v1/status") ); ?>')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
result.style.color = '#16a34a';
result.innerHTML = '✅ <strong>WordPress-Endpoint aktiv!</strong> Plugin v' + (d.version || '?');
} else {
result.style.color = '#dc2626';
result.textContent = '⚠️ Endpoint antwortet, aber Fehler: ' + JSON.stringify(d);
}
})
.catch(function(e) {
result.style.color = '#dc2626';
result.textContent = '❌ Nicht erreichbar: ' + e.message;
})
.finally(function() { btn.disabled = false; });
}
</script>
<p class="description" style="margin-top:.5rem">
Testet ob der WordPress REST-Endpoint <code>/wp-json/mc-bridge/v1/status</code> erreichbar ist.
Danach in <code>verify.properties</code>: <code>forum.wp_url</code> und <code>forum.api_secret</code> eintragen.
</p>
</td>
</tr>
</table>
<div style="background:#fffbeb;border:1px solid #fcd34d;border-radius:6px;padding:1rem;margin-top:1rem;font-size:.875rem">
<strong>⚙️ Einrichtung in 3 Schritten:</strong>
<ol style="margin:.5rem 0 0 1.2rem;padding:0;line-height:1.8">
<li>API Secret hier festlegen und Einstellungen speichern.</li>
<li>In <code>verify.properties</code> des BungeeCord-Plugins setzen:
<br><code>forum.enabled=true</code>
<br><code>forum.wp_url=<?php echo esc_html( get_site_url() ); ?></code>
<br><code>forum.api_secret=DEIN_SECRET</code>
</li>
<li>Spieler können sich nun mit <strong><code>/forumlink &lt;token&gt;</code></strong> ingame verknüpfen.
Den Token generieren sie in ihrem Forum-Profil unter dem Tab <em>Verbindungen</em>.</li>
</ol>
</div>
</div>
<?php submit_button(
'💾 Einstellungen speichern',
'primary',
@@ -506,7 +857,6 @@ function wbf_admin_settings() {
true,
[ 'style' => 'margin-top:1rem' ]
); ?>
</form>
<!-- ── Vorschau-Tabelle ──────────────────────────────── -->
<hr style="margin-top:2.5rem">

View File

@@ -125,8 +125,12 @@ class WBF_Setup {
$page_title = sanitize_text_field($_POST['page_title'] ?? 'Forum');
if ($create_page) {
$existing = get_posts(['post_type'=>'page','s'=>$page_title,'posts_per_page'=>1]);
if (empty($existing)) {
global $wpdb;
$existing_id = $wpdb->get_var( $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status = 'publish' AND post_content LIKE %s LIMIT 1",
'%[business_forum]%'
) );
if (empty($existing_id)) {
$page_id = wp_insert_post([
'post_title' => $page_title,
'post_content' => '[business_forum]',

View File

@@ -1,3 +1,68 @@
/* Shop Orders (Profil) */
.wbf-shop-orders-list {
margin-top: 1.2rem;
}
.wbf-shop-orders-table {
width: 100%;
border-collapse: collapse;
background: var(--c-surface);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-sm);
font-size: .97em;
}
.wbf-shop-orders-table th, .wbf-shop-orders-table td {
padding: .65em 1em;
border-bottom: 1px solid var(--c-border);
}
.wbf-shop-orders-table th {
background: var(--c-surface2);
color: var(--c-text-dim);
font-weight: 600;
font-size: .93em;
text-transform: uppercase;
letter-spacing: .04em;
}
.wbf-shop-orders-table tr:last-child td { border-bottom: none; }
.wbf-shop-order-row {
cursor: pointer;
transition: background .15s;
}
.wbf-shop-order-row:hover {
background: var(--c-primary-l);
}
.wbf-shop-order-details-inner {
background: var(--c-bg2);
border-radius: var(--radius-sm);
padding: 1em 1.2em;
margin: .2em 0 .5em 0;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
color: var(--c-text);
font-size: .98em;
}
.wbf-shop-order-cancelled td, .wbf-shop-order-cancelled .wbf-shop-order-details-inner {
color: var(--c-danger);
text-decoration: line-through;
opacity: .7;
}
.wbf-shop-order-details {
background: var(--c-surface2);
transition: display .2s;
}
.wbf-shop-order-toggle {
background: var(--c-surface2);
border: 1px solid var(--c-border);
color: var(--c-muted);
border-radius: 6px;
padding: 2px 10px;
font-size: 1em;
cursor: pointer;
transition: background .15s, color .15s;
}
.wbf-shop-order-toggle:hover {
background: var(--c-primary-l);
color: var(--c-primary);
}
/* ═══════════════════════════════════════════════════════════
WP Business Forum — Minecraft Modern Dark Theme
═══════════════════════════════════════════════════════════ */
@@ -416,14 +481,57 @@ a.wbf-btn--primary:hover {
align-items: center;
text-align: center;
}
/* Banner */
.wbf-profile-banner {
position: relative;
width: 100%;
height: 120px;
overflow: hidden;
background: linear-gradient(135deg, #0a1628 0%, #162040 50%, #0d1a30 100%);
}
.wbf-profile-banner__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.wbf-profile-banner__placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #0a1628 0%, #162040 50%, #0d1a30 100%);
}
.wbf-banner-upload-btn {
position: absolute;
bottom: 8px;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0,0,0,.55);
backdrop-filter: blur(6px);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background .2s, transform .15s;
font-size: .8rem;
border: 1px solid rgba(255,255,255,.15);
}
.wbf-banner-upload-btn:hover {
background: rgba(0,180,216,.5);
transform: scale(1.1);
}
.wbf-profile-sidebar__avatar-wrap {
position: relative;
padding: 2rem 0 1.25rem;
padding: 0 0 1.25rem;
margin-top: -45px;
width: 100%;
display: flex;
justify-content: center;
background: linear-gradient(160deg, #0d1117, #161d2e);
border-bottom: 1px solid rgba(0,180,216,.12);
z-index: 2;
}
.wbf-profile-sidebar__avatar {
width: 100px; height: 100px;
@@ -557,6 +665,235 @@ a.wbf-btn--primary:hover {
padding-top: 1rem;
border-top: 1px solid var(--c-border);
}
/* ── Verbindungen / Connection Cards ────────────────────────────────────── */
.wbf-connection-card {
display: grid;
grid-template-columns: 56px 1fr;
grid-template-rows: auto auto;
gap: 0 1.1rem;
padding: 1.4rem 1.25rem;
border-bottom: 1px solid var(--c-border);
transition: background .15s;
}
.wbf-connection-card:last-child { border-bottom: none; }
.wbf-connection-card:hover { background: rgba(255,255,255,.025); }
.wbf-connection-card__icon {
grid-column: 1;
grid-row: 1 / 3;
width: 48px;
height: 48px;
border-radius: 12px;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.35rem;
align-self: center;
}
/* Titelzeile — rechts oben */
.wbf-connection-card__head {
grid-column: 2;
grid-row: 1;
display: flex;
align-items: center;
gap: .6rem;
margin-bottom: .5rem;
}
.wbf-connection-card__body {
flex: 1;
min-width: 0;
}
.wbf-connection-card__title {
font-weight: 700;
font-size: .92rem;
color: var(--c-text);
line-height: 1;
}
.wbf-connection-card__desc {
font-size: .82rem;
color: var(--c-muted);
line-height: 1.55;
margin-bottom: .85rem;
}
/* Content-Bereich — grid row 2, rechte Spalte */
.wbf-connection-card__content {
grid-column: 2;
grid-row: 2;
}
/* Plugin-Output normalisieren */
.wbf-connection-card__content p {
font-size: .8rem;
color: var(--c-muted);
line-height: 1.5;
margin: 0 0 .7rem;
font-weight: 400 !important;
}
.wbf-connection-card__content label {
display: block;
font-size: .75rem;
font-weight: 600;
color: var(--c-text-dim);
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: .35rem;
}
.wbf-connection-card__content .wbf-mc-row,
.wbf-connection-card__content > form,
.wbf-connection-card__content > div {
display: flex;
align-items: center;
gap: .55rem;
flex-wrap: wrap;
}
/* Input + Button auf einer Linie halten */
.wbf-connect-row {
display: flex !important;
align-items: center !important;
gap: .55rem !important;
flex-wrap: nowrap !important;
}
.wbf-connect-row input[type="text"] {
flex: 1 1 0;
min-width: 0;
max-width: none !important;
}
.wbf-connect-row button,
.wbf-connect-row .wbf-btn {
flex-shrink: 0;
white-space: nowrap;
}
/* Alle Verknüpfen-Buttons in Connection-Cards vereinheitlichen */
.wbf-connection-card__content .wbf-btn,
.wbf-connection-card__content button:not(.wbf-bb-spoiler__btn) {
padding: .5rem 1rem !important;
font-size: .83rem !important;
font-weight: 600 !important;
height: 2.25rem !important;
line-height: 1 !important;
display: inline-flex !important;
align-items: center !important;
gap: .4rem !important;
border-radius: var(--radius-sm) !important;
border: 1.5px solid transparent !important;
cursor: pointer !important;
font-family: inherit !important;
transition: var(--transition) !important;
white-space: nowrap !important;
}
/* Primär (Verknüpfen / Code senden) */
.wbf-connection-card__content .wbf-btn--primary,
.wbf-connection-card__content button.wbf-btn--primary {
background: var(--c-primary) !important;
color: #fff !important;
border-color: var(--c-primary) !important;
box-shadow: 0 0 10px rgba(0,180,216,.25) !important;
}
.wbf-connection-card__content .wbf-btn--primary:hover {
background: var(--c-primary-d) !important;
border-color: var(--c-primary-d) !important;
}
/* Ghost (Zurück / Trennen) */
.wbf-connection-card__content .wbf-btn--ghost,
.wbf-connection-card__content button.wbf-btn--ghost {
background: transparent !important;
color: var(--c-text-dim) !important;
border-color: var(--c-border-d) !important;
}
.wbf-connection-card__content .wbf-btn--ghost:hover {
border-color: var(--c-primary) !important;
color: var(--c-primary) !important;
}
/* MC-Plugin: dessen Button via content selector normalisieren */
.wbf-connection-card__content input[type="submit"],
.wbf-connection-card__content .mc-connect-btn {
padding: .5rem 1rem !important;
height: 2.25rem !important;
font-size: .83rem !important;
font-weight: 600 !important;
background: var(--c-primary) !important;
color: #fff !important;
border: 1.5px solid var(--c-primary) !important;
border-radius: var(--radius-sm) !important;
cursor: pointer !important;
font-family: inherit !important;
white-space: nowrap !important;
display: inline-flex !important;
align-items: center !important;
}
.wbf-connection-card__content input[type="text"] {
flex: 1;
min-width: 160px;
max-width: 280px;
padding: .45rem .75rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 6px);
background: var(--c-bg);
color: var(--c-text);
font-size: .88rem;
}
.wbf-connection-card__content input[type="text"]:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(0,180,216,.15);
}
.wbf-connection-card__content button,
.wbf-connection-card__content .wbf-btn {
white-space: nowrap;
}
/* Status-Badge in der Card (verbunden / nicht verbunden) */
.wbf-connection-badge {
display: inline-flex;
align-items: center;
gap: .35rem;
font-size: .78rem;
font-weight: 600;
padding: .25rem .65rem;
border-radius: 20px;
}
.wbf-connection-badge--connected {
color: #16a34a;
background: rgba(22,163,74,.12);
border: 1px solid rgba(22,163,74,.25);
}
.wbf-connection-badge--disconnected {
color: var(--c-muted);
background: rgba(148,163,184,.08);
border: 1px solid var(--c-border);
}
/* Discord-spezifische Farben */
.wbf-connection-card--discord .wbf-connection-card__icon {
background: rgba(88,101,242,.15);
border-color: rgba(88,101,242,.3);
}
.wbf-connection-card--discord .wbf-connection-card__icon i {
color: #5865f2;
}
/* Verbunden-Info */
.wbf-discord-linked-name {
display: inline-flex;
align-items: center;
gap: .4rem;
font-weight: 700;
font-size: .92rem;
color: var(--c-text);
padding: .35rem .75rem;
background: rgba(88,101,242,.1);
border: 1px solid rgba(88,101,242,.25);
border-radius: 20px;
}
.wbf-profile-edit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1816,10 +2153,15 @@ select.wbf-cf-input option { background: var(--c-surface2); color: var(--c-text)
text-align: left;
}
.wbf-profile-sidebar__avatar-wrap {
padding: 1.25rem;
padding: 0 0 1rem;
margin-top: -45px;
width: auto;
border-bottom: none;
border-right: 1px solid var(--c-border);
border-right: none;
}
.wbf-profile-banner {
height: 100px;
width: 100%;
}
.wbf-profile-sidebar__identity {
align-items: flex-start;
@@ -3718,3 +4060,229 @@ select.wbf-cf-input option { background: var(--c-surface2); color: var(--c-text)
.wbf-prof__stat-cards { flex-direction: column; }
.wbf-prof__badges { grid-template-columns: repeat(3,1fr); }
}
/* ═══════════════════════════════════════════════════════════════════════════
2FA — Zwei-Faktor-Authentifizierung
═══════════════════════════════════════════════════════════════════════════ */
/* Badge im Profil-Card-Header */
.wbf-2fa-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: .72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
margin-left: auto;
letter-spacing: .02em;
}
.wbf-2fa-badge--on {
background: rgba(34,197,94,.15);
color: #16a34a;
border: 1px solid rgba(34,197,94,.3);
}
.wbf-2fa-badge--off {
background: rgba(156,163,175,.12);
color: var(--c-muted);
border: 1px solid rgba(156,163,175,.2);
}
/* QR-Code Container */
/* QR-Code — QRCode.js erzeugt img UND canvas; img ausblenden, nur canvas zeigen */
#wbf2faQr {
display: inline-block;
padding: 12px;
background: #fff;
border-radius: 8px;
line-height: 0;
box-shadow: 0 1px 6px rgba(0,0,0,.15);
align-self: flex-start;
flex-shrink: 0;
}
/* Das leere Duplikat-img von QRCode.js komplett verstecken */
#wbf2faQr img {
display: none !important;
}
/* Nur das canvas anzeigen — exakte quadratische Größe erzwingen */
#wbf2faQr canvas {
display: block !important;
width: 200px !important;
height: 200px !important;
}
/* Secret-Code (manuelle Eingabe) */
#wbf2faSecret {
display: inline-block;
font-family: 'Courier New', monospace;
font-size: .9rem;
letter-spacing: .12em;
background: var(--c-bg-2, rgba(255,255,255,.05));
border: 1px solid rgba(255,255,255,.1);
padding: 5px 12px;
border-radius: 6px;
user-select: all;
cursor: text;
word-break: break-all;
}
/* 2FA-Login-Step (im Auth-Modal) */
.wbf-2fa-login-step {
animation: wbfFadeIn .2s ease;
}
.wbf-2fa-login-step input {
width: 100%;
box-sizing: border-box;
font-family: monospace;
text-align: center;
font-size: 1.4rem;
letter-spacing: .3em;
padding: 10px 14px;
border: 1px solid rgba(234,179,8,.4);
border-radius: 7px;
background: var(--c-bg, #1e2535);
color: var(--c-text, #e2e8f0);
transition: border-color .15s;
}
.wbf-2fa-login-step input:focus {
outline: none;
border-color: #eab308;
box-shadow: 0 0 0 2px rgba(234,179,8,.2);
}
/* Verify-Code-Input im Profil */
#wbf2faVerifyCode {
font-family: monospace;
letter-spacing: .25em;
font-size: 1.3rem;
text-align: center;
transition: border-color .15s;
}
#wbf2faVerifyCode:focus {
border-color: var(--c-primary) !important;
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb, 99,102,241), .2);
}
/* Schritt-3-Erfolgs-Animation */
#wbf2faStep3 {
animation: wbfFadeIn .3s ease;
}
@keyframes wbfFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── 2FA Login Modal ─────────────────────────────────────────────────────── */
.wbf-2fa-modal-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
transition: background .25s ease, backdrop-filter .25s ease;
padding: 16px;
}
.wbf-2fa-modal-overlay.wbf-2fa-modal--visible {
background: rgba(0, 0, 0, .65);
backdrop-filter: blur(4px);
}
.wbf-2fa-modal-box {
background: var(--c-bg-2, #1e2535);
border: 1px solid rgba(234,179,8,.35);
border-radius: 14px;
padding: 28px 28px 24px;
width: 100%;
max-width: 380px;
box-shadow: 0 24px 60px rgba(0,0,0,.5), 0 0 0 1px rgba(234,179,8,.1);
transform: translateY(20px) scale(.97);
opacity: 0;
transition: transform .25s cubic-bezier(.34,1.3,.64,1), opacity .2s ease;
}
.wbf-2fa-modal--visible .wbf-2fa-modal-box {
transform: translateY(0) scale(1);
opacity: 1;
}
/* Modal Header */
.wbf-2fa-modal-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
}
.wbf-2fa-modal-icon {
font-size: 1.8rem;
line-height: 1;
flex-shrink: 0;
}
.wbf-2fa-modal-title {
display: block;
font-size: 1rem;
font-weight: 700;
color: var(--c-text, #e2e8f0);
margin-bottom: 3px;
}
.wbf-2fa-modal-sub {
margin: 0;
font-size: .82rem;
color: var(--c-muted, #94a3b8);
line-height: 1.4;
}
/* Code-Eingabe im Modal */
.wbf-2fa-modal-box .wbf-2fa-code-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 12px 16px;
font-size: 1.6rem;
letter-spacing: .35em;
text-align: center;
font-family: 'Courier New', monospace;
font-weight: 600;
background: var(--c-bg, #141928);
color: var(--c-text, #e2e8f0);
border: 2px solid rgba(234,179,8,.3);
border-radius: 10px;
outline: none;
transition: border-color .15s, box-shadow .15s;
margin-bottom: 16px;
}
.wbf-2fa-modal-box .wbf-2fa-code-input:focus {
border-color: #eab308;
box-shadow: 0 0 0 3px rgba(234,179,8,.2);
}
.wbf-2fa-modal-box .wbf-2fa-code-input::placeholder {
color: rgba(148,163,184,.35);
letter-spacing: .25em;
}
/* Buttons im Modal */
.wbf-2fa-modal-actions {
display: flex;
gap: 10px;
}
.wbf-2fa-modal-actions .wbf-btn--primary {
flex: 1;
}
.wbf-2fa-modal-actions .wbf-2fa-cancel-btn {
font-size: .83rem;
padding: 7px 14px;
opacity: .65;
transition: opacity .15s;
}
.wbf-2fa-modal-actions .wbf-2fa-cancel-btn:hover {
opacity: 1;
}
/* Fehlermeldung */
.wbf-2fa-modal-box .wbf-2fa-msg {
display: block;
min-height: 1.2em;
margin-top: 10px;
font-size: .82rem;
text-align: center;
}

View File

@@ -3,6 +3,10 @@
/* ── Utilities ──────────────────────────────────────────────── */
function wbfPost(action, data, cb, errCb) {
if (typeof WBF === 'undefined' || !WBF.ajax_url || !WBF.nonce) {
if (errCb) errCb({message: 'Forum-Fehler: AJAX-Setup fehlt. Bitte Seite neu laden.'});
return;
}
data.action = action;
data.nonce = WBF.nonce;
$.post(WBF.ajax_url, data, function (res) {
@@ -52,12 +56,18 @@
/* ── Registrieren ───────────────────────────────────────────── */
$(document).on('click', '.wbf-reg-submit-btn', function () {
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
var $invite = $(this).closest('.wbf-auth-box').find('.wbf-field-invite-code');
var inviteVal = '';
if ($invite.length > 0) {
var raw = $invite.val();
if (typeof raw === 'string') inviteVal = raw.toUpperCase().trim();
}
wbfPost('wbf_register', {
username: $(this).closest('.wbf-auth-box').find('.wbf-field-reg-user').val(),
display_name: $(this).closest('.wbf-auth-box').find('.wbf-field-reg-name').val(),
email: $(this).closest('.wbf-auth-box').find('.wbf-field-reg-email').val(),
password: $(this).closest('.wbf-auth-box').find('.wbf-field-reg-pass').val(),
invite_code: $(this).closest('.wbf-auth-box').find('.wbf-field-invite-code').val().toUpperCase().trim(),
invite_code: inviteVal,
rules_accepted: $(this).closest('.wbf-auth-box').find('.wbf-field-rules-accept').is(':checked') ? '1' : ''
}, function () {
location.reload();
@@ -526,6 +536,13 @@
wbfPost('wbf_update_profile', data, function (d) {
showMsg($msg, d.message, true);
$btn.prop('disabled', false);
// Bio und Signatur sofort aktualisieren (ohne Reload)
if (typeof data.bio !== 'undefined') {
$('.wbf-profile-sidebar__bio-text').text(data.bio);
}
if (typeof data.signature !== 'undefined') {
$('.wbf-profile-sidebar__sig').text(data.signature);
}
}, function (d) {
showMsg($msg, d.message || 'Fehler', false);
$btn.prop('disabled', false);
@@ -609,6 +626,48 @@
});
});
// ── Banner-Upload ─────────────────────────────────────────────────────────
$(document).on('change', '#wbfBannerFile', function () {
var file = this.files[0];
if (!file) return;
// Sofort-Vorschau
var objectUrl = URL.createObjectURL(file);
var $wrap = $('#wbfProfileBannerWrap');
var $existing = $wrap.find('.wbf-profile-banner__img');
// Falls noch kein Banner-Bild existiert, eins einfügen
if ($existing.length === 0) {
$wrap.prepend('<img src="' + objectUrl + '" alt="" id="wbfProfileBanner" class="wbf-profile-banner__img" style="opacity:.4">');
} else {
$existing.attr('src', objectUrl).css('opacity', '.4');
}
var fd = new FormData();
fd.append('action', 'wbf_upload_banner');
fd.append('nonce', WBF.nonce);
fd.append('banner', file);
$.ajax({
url: WBF.ajax_url,
type: 'POST',
data: fd,
processData: false,
contentType: false,
success: function (res) {
var $img = $wrap.find('.wbf-profile-banner__img');
if (res.success) {
URL.revokeObjectURL(objectUrl);
$img.attr('src', res.data.banner_url + '?v=' + Date.now());
}
$img.css('opacity', '');
},
error: function () {
$wrap.find('.wbf-profile-banner__img').css('opacity', '');
}
});
});
/* ══════════════════════════════════════════════════════════
FEATURE: Ungelesene Beiträge
══════════════════════════════════════════════════════════ */
@@ -1342,12 +1401,16 @@
'</div>'
].join('')).appendTo('body');
var wbfLogoutFired = false; // Guard gegen doppelten Logout-Call
function wbfDoLogout() {
if (wbfLogoutFired) return; // doppelten Aufruf verhindern
wbfLogoutFired = true;
clearTimeout(wbfIdleTimer);
clearTimeout(wbfWarnTimer);
clearInterval(wbfCountTimer);
$wbfToast.hide();
wbfPost('wbf_logout', {}, function () {
wbfPost('wbf_logout', { nonce: WBF.nonce }, function () {
location.reload();
});
}
@@ -1357,23 +1420,23 @@
var secs = 30;
$('#wbfIdleCountdown').text(secs);
$wbfToast.fadeIn(200);
// Countdown-Interval läuft bis 0 und ruft dann wbfDoLogout() auf —
// kein zusätzlicher setTimeout(wbfDoLogout) nötig (war die Ursache des Doppel-Logouts)
wbfCountTimer = setInterval(function () {
secs--;
$('#wbfIdleCountdown').text(secs);
$('#wbfIdleCountdown').text(Math.max(0, secs));
if (secs <= 0) {
clearInterval(wbfCountTimer);
wbfDoLogout();
}
}, 1000);
// Auto-logout after warning period
wbfIdleTimer = setTimeout(wbfDoLogout, wbfWarnMs);
}
function wbfResetIdleTimer() {
if (wbfWarning) return; // Nutzer hat aktiv Warnung bestätigt — nicht resetten
clearTimeout(wbfIdleTimer);
clearTimeout(wbfWarnTimer);
// Warn 30 sec before timeout
// Warnung 30 Sek. vor Ablauf zeigen
wbfWarnTimer = setTimeout(wbfShowWarning, wbfIdleMs - wbfWarnMs);
}
@@ -1383,6 +1446,7 @@
clearInterval(wbfCountTimer);
$wbfToast.fadeOut(200);
wbfWarning = false;
wbfLogoutFired = false; // Guard zurücksetzen
wbfResetIdleTimer();
});
@@ -2163,4 +2227,132 @@
$bar.hide();
});
// ── Discord-Integration (3-Schritt Verifikation) ─────────────────────────
var wbfDcStep = 1; // aktueller Schritt
function wbfDcMsg(text, color) {
var $m = $('#wbf-discord-msg');
$m.css('color', color || 'var(--c-muted)').html(text);
}
function wbfDcSetBadge(connected) {
var $badge = $('.wbf-connection-card--discord .wbf-connection-badge');
if (connected) {
$badge.removeClass('wbf-connection-badge--disconnected')
.addClass('wbf-connection-badge--connected')
.html('<i class="fas fa-check-circle"></i> Verbunden');
} else {
$badge.removeClass('wbf-connection-badge--connected')
.addClass('wbf-connection-badge--disconnected')
.html('<i class="fas fa-circle-xmark"></i> Nicht verbunden');
}
}
// Schritt 1 → Code senden
$(document).on('click', '#wbf-discord-send-code', function () {
var username = $('#wbf-discord-input').val().trim();
if (!username) { wbfDcMsg('<i class="fas fa-triangle-exclamation"></i> Bitte Benutzername eingeben.', '#f97316'); return; }
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sende…');
wbfDcMsg('');
$.post(WBF.ajax_url, {
action: 'wbf_discord_send_code',
nonce: WBF.nonce,
discord_username: username,
}, function (res) {
$btn.prop('disabled', false).html('<i class="fab fa-discord"></i> Code senden');
if (res.success) {
wbfDcMsg('<i class="fas fa-check" style="color:#16a34a"></i> ' + (res.data.message || 'Code gesendet!'), '#16a34a');
$('#wbf-dc-step1').slideUp(200, function () { $('#wbf-dc-step2').slideDown(200); });
$('#wbf-discord-code-input').val('').focus();
wbfDcStep = 2;
} else {
wbfDcMsg('<i class="fas fa-circle-xmark"></i> ' + ((res.data && res.data.message) || 'Fehler.'), '#dc2626');
}
}).fail(function () {
$btn.prop('disabled', false).html('<i class="fab fa-discord"></i> Code senden');
wbfDcMsg('<i class="fas fa-circle-xmark"></i> Netzwerkfehler.', '#dc2626');
});
});
// Schritt 2 → Code bestätigen
$(document).on('click', '#wbf-discord-verify', function () {
var code = $('#wbf-discord-code-input').val().trim().toUpperCase();
if (code.length < 4) { wbfDcMsg('<i class="fas fa-triangle-exclamation"></i> Bitte Code eingeben.', '#f97316'); return; }
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Prüfe…');
$.post(WBF.ajax_url, {
action: 'wbf_discord_verify_code',
nonce: WBF.nonce,
verify_code: code,
}, function (res) {
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Bestätigen');
if (res.success) {
wbfDcMsg('<i class="fas fa-check-circle"></i> ' + (res.data.message || 'Verbunden!'), '#16a34a');
wbfDcSetBadge(true);
// UI auf "Verbunden"-Ansicht umschalten
var name = res.data.display_name || '';
$('#wbf-discord-form').slideUp(200);
// Verbunden-Info einfügen/aktualisieren
var $info = $('.wbf-discord-connected-info');
if ($info.length) {
$info.find('.wbf-discord-linked-name').html('<i class="fab fa-discord" style="color:#5865f2"></i> ' + $('<span>').text(name).html());
} else {
// Frisch laden damit die PHP-Struktur stimmt
setTimeout(function(){ location.reload(); }, 1200);
}
} else {
wbfDcMsg('<i class="fas fa-circle-xmark"></i> ' + ((res.data && res.data.message) || 'Fehler.'), '#dc2626');
}
}).fail(function () {
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Bestätigen');
wbfDcMsg('<i class="fas fa-circle-xmark"></i> Netzwerkfehler.', '#dc2626');
});
});
// Enter-Taste auf Code-Feld
$(document).on('keydown', '#wbf-discord-code-input', function (e) {
if (e.key === 'Enter') { e.preventDefault(); $('#wbf-discord-verify').trigger('click'); }
});
// Enter-Taste auf Username-Feld
$(document).on('keydown', '#wbf-discord-input', function (e) {
if (e.key === 'Enter') { e.preventDefault(); $('#wbf-discord-send-code').trigger('click'); }
});
// „Zurück" in Schritt 2
$(document).on('click', '#wbf-discord-code-back', function () {
$('#wbf-dc-step2').slideUp(200, function () { $('#wbf-dc-step1').slideDown(200); });
wbfDcMsg('');
wbfDcStep = 1;
});
// „Neu verknüpfen" bei bereits verbundenem Account
$(document).on('click', '#wbf-discord-relink', function () {
$('#wbf-discord-form').slideDown(200);
$('#wbf-discord-input').val('').focus();
});
// Verbindung trennen
$(document).on('click', '#wbf-discord-disconnect', function () {
if (!confirm('Discord-Verbindung wirklich trennen?')) return;
var $btn = $(this).prop('disabled', true);
$.post(WBF.ajax_url, {
action: 'wbf_save_discord',
nonce: WBF.nonce,
sub_action: 'disconnect',
}, function (res) {
$btn.prop('disabled', false);
if (res.success) {
wbfDcMsg('<i class="fas fa-check"></i> ' + (res.data.message || 'Getrennt.'), '#16a34a');
wbfDcSetBadge(false);
setTimeout(function () { location.reload(); }, 900);
} else {
wbfDcMsg('<i class="fas fa-circle-xmark"></i> ' + ((res.data && res.data.message) || 'Fehler.'), '#dc2626');
}
});
});
}(jQuery));
// Overwrite last line — Discord handlers appended via patch:

View File

@@ -7,7 +7,7 @@ class WBF_Ajax {
$actions = [
'wbf_login', 'wbf_register', 'wbf_logout',
'wbf_new_thread', 'wbf_new_post', 'wbf_toggle_like',
'wbf_update_profile', 'wbf_upload_avatar', 'wbf_upload_post_image',
'wbf_update_profile', 'wbf_upload_avatar', 'wbf_upload_post_image', 'wbf_upload_banner',
'wbf_forgot_password', 'wbf_reset_password', 'wbf_load_more_messages',
'wbf_create_invite', 'wbf_delete_invite',
'wbf_toggle_subscribe', 'wbf_restore_content', 'wbf_toggle_profile_visibility',
@@ -21,6 +21,13 @@ class WBF_Ajax {
'wbf_toggle_ignore',
'wbf_change_email',
'wbf_save_notification_prefs',
'wbf_save_discord',
'wbf_discord_send_code',
'wbf_discord_verify_code',
'wbf_2fa_setup_begin',
'wbf_2fa_setup_verify',
'wbf_2fa_disable',
'wbf_2fa_verify_login',
];
foreach ($actions as $action) {
add_action('wp_ajax_nopriv_' . $action, [__CLASS__, str_replace('wbf_','handle_',$action)]);
@@ -56,7 +63,8 @@ class WBF_Ajax {
// Login braucht keinen Nonce — Credentials sind die Authentifizierung
$result = WBF_Auth::login(
sanitize_text_field($_POST['username'] ?? ''),
$_POST['password'] ?? ''
$_POST['password'] ?? '',
! empty($_POST['remember_me'])
);
if ($result['success']) {
// Erfolgreicher Login: Fehlzähler löschen
@@ -66,6 +74,9 @@ class WBF_Ajax {
WBF_Auth::set_remember_cookie($u->id);
}
wp_send_json_success(['display_name'=>$u->display_name,'avatar_url'=>$u->avatar_url,'user_id'=>$u->id]);
} elseif ( ! empty($result['2fa_required']) ) {
// 2FA erforderlich — kein Fehlerzähler erhöhen, kein Fehlermeldung
wp_send_json_error(['2fa_required' => true]);
} else {
// Fehlversuch zählen — außer bei gesperrten Konten (kein Passwortfehler)
if ( empty($result['banned']) ) {
@@ -137,7 +148,10 @@ class WBF_Ajax {
}
public static function handle_logout() {
// Kein Nonce-Check für Logout nötig — Session-Clearing ist sicher
// Nonce-Check für Logout
if ( ! isset($_POST['nonce']) || ! check_ajax_referer('wbf_nonce', 'nonce', false) ) {
wp_send_json_error(['message' => 'invalid_nonce'], 403);
}
WBF_Auth::logout();
wp_send_json_success(['message' => 'logged_out']);
}
@@ -185,6 +199,10 @@ class WBF_Ajax {
'content' => WBF_DB::apply_word_filter($content),
'prefix_id' => $prefix_id,
]);
// Ingame-Benachrichtigung
if (function_exists('wbf_notify_ingame')) {
wbf_notify_ingame($user->username, 'Neuer Thread: ' . mb_substr($title, 0, 80));
}
// Tags speichern
$raw_tags = sanitize_text_field( $_POST['tags'] ?? '' );
@@ -507,6 +525,52 @@ class WBF_Ajax {
wp_send_json_success(['avatar_url'=>$url]);
}
public static function handle_upload_banner() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error(['message' => 'Nicht eingeloggt.']);
if ( empty($_FILES['banner']) ) wp_send_json_error(['message' => 'Keine Datei.']);
$allowed_types = ['image/jpeg','image/png','image/gif','image/webp'];
if ( $_FILES['banner']['size'] > 3 * 1024 * 1024 ) {
wp_send_json_error(['message' => 'Maximale Dateigröße: 3 MB.']);
}
$tmp = $_FILES['banner']['tmp_name'] ?? '';
if ( ! $tmp || ! is_uploaded_file( $tmp ) ) {
wp_send_json_error(['message' => 'Ungültiger Datei-Upload.']);
}
if ( function_exists('finfo_open') ) {
$finfo = finfo_open( FILEINFO_MIME_TYPE );
$real_mime = finfo_file( $finfo, $tmp );
finfo_close( $finfo );
} else {
$et_map = [
IMAGETYPE_JPEG => 'image/jpeg',
IMAGETYPE_PNG => 'image/png',
IMAGETYPE_GIF => 'image/gif',
IMAGETYPE_WEBP => 'image/webp',
];
$et = @exif_imagetype( $tmp );
$real_mime = $et_map[$et] ?? '';
}
if ( ! in_array( $real_mime, $allowed_types, true ) ) {
wp_send_json_error(['message' => 'Nur JPG, PNG, GIF und WebP erlaubt.']);
}
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
$id = media_handle_upload('banner', 0);
if ( is_wp_error($id) ) wp_send_json_error(['message' => $id->get_error_message()]);
$url = wp_get_attachment_url($id);
WBF_DB::update_user($user->id, ['banner_url' => $url]);
wp_send_json_success(['banner_url' => $url]);
}
// ── Report ────────────────────────────────────────────────────────────────
public static function handle_report_post() {
@@ -1447,6 +1511,319 @@ class WBF_Ajax {
] );
}
// ── Discord: Verifikations-Code per Bot-DM senden ─────────────────────────
public static function handle_discord_send_code() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error(['message' => 'Nicht eingeloggt.']);
$s = wbf_get_settings();
$token = trim($s['discord_bot_token'] ?? '');
$guild = trim($s['discord_guild_id'] ?? '');
if ( ! $token ) {
wp_send_json_error(['message' => 'Discord-Bot ist noch nicht konfiguriert. Bitte wende dich an einen Admin.']);
}
$username_input = sanitize_text_field($_POST['discord_username'] ?? '');
if ( ! $username_input ) {
wp_send_json_error(['message' => 'Bitte Discord-Benutzername eingeben.']);
}
// Nutzer auf dem Guild suchen (nach Username oder per Search)
$discord_user_id = self::discord_find_user_id($username_input, $token, $guild);
if ( ! $discord_user_id ) {
wp_send_json_error(['message' => 'Discord-Nutzer nicht auf dem Server gefunden. Stelle sicher, dass du Mitglied des Servers bist.']);
}
// Verifikations-Code generieren (6-stellig)
$code = strtoupper(substr(md5(uniqid(mt_rand(), true)), 0, 6));
$expires = time() + 600; // 10 Minuten
// Code + Discord-User-ID temporär speichern
WBF_DB::set_user_meta($user->id, 'discord_verify_code', $code);
WBF_DB::set_user_meta($user->id, 'discord_verify_expires', (string)$expires);
WBF_DB::set_user_meta($user->id, 'discord_verify_pending_id', $discord_user_id);
// DM senden
$sent = self::discord_send_dm($discord_user_id, $token,
"🔐 **Dein Verifikationscode für " . get_bloginfo('name') . ":**\n\n" .
"```" . $code . "```\n" .
"Gib diesen Code im Forum ein. Er ist **10 Minuten** gültig.\n" .
"_Falls du diese Nachricht nicht erwartet hast, ignoriere sie einfach._"
);
if ( ! $sent ) {
wp_send_json_error(['message' => 'DM konnte nicht gesendet werden. Stelle sicher, dass du DMs von Server-Mitgliedern zulässt.']);
}
wp_send_json_success(['message' => '✅ Code gesendet! Prüfe deine Discord-DMs und gib den 6-stelligen Code ein.', 'step' => 'enter_code']);
}
// ── Discord: Code überprüfen + Verbindung herstellen ─────────────────────
public static function handle_discord_verify_code() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error(['message' => 'Nicht eingeloggt.']);
$code_input = strtoupper(sanitize_text_field($_POST['verify_code'] ?? ''));
$meta = WBF_DB::get_user_meta($user->id);
$stored_code = strtoupper($meta['discord_verify_code'] ?? '');
$expires = (int)($meta['discord_verify_expires'] ?? 0);
$discord_uid = $meta['discord_verify_pending_id'] ?? '';
if ( ! $stored_code || ! $discord_uid ) {
wp_send_json_error(['message' => 'Kein offener Verifizierungs-Vorgang. Bitte erneut starten.']);
}
if ( time() > $expires ) {
wp_send_json_error(['message' => 'Code abgelaufen. Bitte erneut einen Code anfordern.']);
}
if ( ! hash_equals($stored_code, $code_input) ) {
wp_send_json_error(['message' => 'Falscher Code. Bitte erneut versuchen.']);
}
// Discord-Username abrufen (für Anzeige)
$s = wbf_get_settings();
$token = trim($s['discord_bot_token'] ?? '');
$display_name = $discord_uid;
if ( $token ) {
$res = wp_remote_get("https://discord.com/api/v10/users/{$discord_uid}", [
'timeout' => 5,
'headers' => ['Authorization' => 'Bot ' . $token],
]);
if ( ! is_wp_error($res) && wp_remote_retrieve_response_code($res) === 200 ) {
$d = json_decode(wp_remote_retrieve_body($res), true);
$display_name = $d['global_name'] ?? $d['username'] ?? $discord_uid;
}
}
// Speichern
WBF_DB::set_user_meta($user->id, 'discord_user_id', $discord_uid);
WBF_DB::set_user_meta($user->id, 'discord_username', $display_name);
// Temp-Daten löschen
WBF_DB::set_user_meta($user->id, 'discord_verify_code', '');
WBF_DB::set_user_meta($user->id, 'discord_verify_expires', '');
WBF_DB::set_user_meta($user->id, 'discord_verify_pending_id', '');
// Rollen-Sync direkt nach Verifikation
$guild = trim($s['discord_guild_id'] ?? '');
$role_map = json_decode($s['discord_role_map'] ?? '{}', true) ?: [];
if ( ($s['discord_role_sync'] ?? '0') === '1' && $token && $guild && $role_map ) {
wbf_sync_discord_role_for_user($user->id, $discord_uid, $token, $guild, $role_map);
}
wp_send_json_success([
'message' => '🎉 Discord erfolgreich verknüpft!',
'connected' => true,
'display_name' => esc_html($display_name),
]);
}
// ── Discord: Verbindung trennen ───────────────────────────────────────────
public static function handle_save_discord() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error(['message' => 'Nicht eingeloggt.']);
$action = sanitize_key( $_POST['sub_action'] ?? 'save' );
if ( $action === 'disconnect' ) {
WBF_DB::set_user_meta($user->id, 'discord_username', '');
WBF_DB::set_user_meta($user->id, 'discord_user_id', '');
wp_send_json_success(['message' => 'Discord-Verbindung getrennt.', 'connected' => false]);
}
wp_send_json_error(['message' => 'Unbekannte Aktion.']);
}
// ── Discord Hilfsmethoden ─────────────────────────────────────────────────
private static function discord_find_user_id($username_input, $token, $guild) {
if ( ! $guild ) return null;
// Guild-Member-Search (max. 1 Treffer)
$search = rawurlencode($username_input);
$res = wp_remote_get("https://discord.com/api/v10/guilds/{$guild}/members/search?query={$search}&limit=5", [
'timeout' => 6,
'headers' => ['Authorization' => 'Bot ' . $token],
]);
if ( is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 200 ) return null;
$members = json_decode(wp_remote_retrieve_body($res), true);
if ( empty($members) ) return null;
// Exakten Treffer bevorzugen
$input_lower = strtolower($username_input);
foreach ( $members as $m ) {
$uname = strtolower($m['user']['username'] ?? '');
$global = strtolower($m['user']['global_name'] ?? '');
if ( $uname === $input_lower || $global === $input_lower ) {
return $m['user']['id'];
}
}
// Erster Treffer als Fallback
return $members[0]['user']['id'] ?? null;
}
private static function discord_send_dm($user_id, $token, $message) {
// DM-Channel erstellen
$ch_res = wp_remote_post('https://discord.com/api/v10/users/@me/channels', [
'timeout' => 6,
'headers' => ['Authorization' => 'Bot ' . $token, 'Content-Type' => 'application/json'],
'body' => json_encode(['recipient_id' => $user_id]),
]);
if ( is_wp_error($ch_res) || wp_remote_retrieve_response_code($ch_res) !== 200 ) return false;
$channel = json_decode(wp_remote_retrieve_body($ch_res), true);
$ch_id = $channel['id'] ?? '';
if ( ! $ch_id ) return false;
// Nachricht senden
$msg_res = wp_remote_post("https://discord.com/api/v10/channels/{$ch_id}/messages", [
'timeout' => 6,
'headers' => ['Authorization' => 'Bot ' . $token, 'Content-Type' => 'application/json'],
'body' => json_encode(['content' => $message]),
]);
return ( ! is_wp_error($msg_res) && wp_remote_retrieve_response_code($msg_res) === 200 );
}
// ══════════════════════════════════════════════════════════════════════════
// ── 2FA / TOTP ────────────────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Setup-Schritt 1: Neues Secret generieren und als "pending" speichern.
* Gibt Secret (zur manuellen Eingabe) und otpauth:// URI (für QR-Code) zurück.
*/
public static function handle_2fa_setup_begin() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error( ["message" => "Nicht eingeloggt."] );
if ( ! class_exists("WBF_TOTP") ) wp_send_json_error( ["message" => "2FA-Modul nicht verfügbar."] );
$secret = WBF_TOTP::generate_secret();
WBF_DB::set_user_meta( $user->id, WBF_TOTP::META_PENDING, $secret );
wp_send_json_success( [
"secret" => $secret,
"uri" => WBF_TOTP::get_otpauth_uri( $user->username, $secret ),
] );
}
/**
* Setup-Schritt 2: Code verifizieren und 2FA aktivieren.
*/
public static function handle_2fa_setup_verify() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error( ["message" => "Nicht eingeloggt."] );
if ( ! class_exists("WBF_TOTP") ) wp_send_json_error( ["message" => "2FA-Modul nicht verfügbar."] );
$code = preg_replace( "/\s+/", "", sanitize_text_field( $_POST["code"] ?? "" ) );
$secret = WBF_DB::get_user_meta_single( $user->id, WBF_TOTP::META_PENDING );
if ( empty($secret) ) {
wp_send_json_error( ["message" => "Kein ausstehender 2FA-Setup. Bitte neu starten."] );
}
if ( ! WBF_TOTP::verify( $secret, $code ) ) {
wp_send_json_error( ["message" => "Ungültiger Code. Bitte Uhrzeit prüfen und erneut versuchen."] );
}
WBF_DB::set_user_meta( $user->id, WBF_TOTP::META_SECRET, $secret );
global $wpdb;
$wpdb->delete( "{$wpdb->prefix}forum_user_meta",
["user_id" => $user->id, "meta_key" => WBF_TOTP::META_PENDING], ["%d", "%s"] );
wp_send_json_success( ["message" => "2FA erfolgreich aktiviert!"] );
}
/**
* 2FA deaktivieren (User-seitig).
* Erfordert aktuelles Passwort + gültigen TOTP-Code.
*/
public static function handle_2fa_disable() {
self::verify();
$user = WBF_Auth::get_current_user();
if ( ! $user ) wp_send_json_error( ["message" => "Nicht eingeloggt."] );
if ( ! class_exists("WBF_TOTP") ) wp_send_json_error( ["message" => "2FA-Modul nicht verfügbar."] );
$password = $_POST["password"] ?? "";
$code = preg_replace( "/\s+/", "", sanitize_text_field( $_POST["code"] ?? "" ) );
$fresh = WBF_DB::get_user( $user->id );
if ( ! $fresh || ! password_verify( $password, $fresh->password ) ) {
wp_send_json_error( ["message" => "Falsches Passwort."] );
}
$secret = WBF_DB::get_user_meta_single( $user->id, WBF_TOTP::META_SECRET );
if ( empty($secret) ) {
wp_send_json_error( ["message" => "2FA ist nicht aktiv."] );
}
if ( ! WBF_TOTP::verify( $secret, $code ) ) {
wp_send_json_error( ["message" => "Ungültiger Authenticator-Code."] );
}
WBF_TOTP::disable_for( $user->id );
wp_send_json_success( ["message" => "2FA wurde deaktiviert."] );
}
/**
* Login-Schritt 2: TOTP-Code nach erfolgreichem Passwort prüfen.
* Kein Nonce — ausstehende Session-ID ist der Auth-Beweis.
* Brute-Force-Schutz: max. 5 Versuche / IP / 10 Min.
*/
public static function handle_2fa_verify_login() {
WBF_Auth::init();
$ip_key = "wbf_2fa_fail_" . md5( $_SERVER["REMOTE_ADDR"] ?? "unknown" );
$fails = (int) get_transient( $ip_key );
if ( $fails >= 5 ) {
wp_send_json_error( ["message" => "Zu viele Fehlversuche. Bitte warte 10 Minuten.", "locked" => true] );
}
$pending = (int) ( $_SESSION[ WBF_TOTP::SESSION_PENDING ] ?? 0 );
if ( ! $pending ) {
wp_send_json_error( ["message" => "Keine ausstehende Anmeldung. Bitte erneut einloggen."] );
}
$code = preg_replace( "/\s+/", "", sanitize_text_field( $_POST["code"] ?? "" ) );
$user = WBF_DB::get_user( $pending );
if ( ! $user ) {
unset( $_SESSION[ WBF_TOTP::SESSION_PENDING ] );
wp_send_json_error( ["message" => "Ungültige Sitzung."] );
}
$secret = WBF_DB::get_user_meta_single( $user->id, WBF_TOTP::META_SECRET );
if ( empty($secret) || ! WBF_TOTP::verify( $secret, $code ) ) {
set_transient( $ip_key, $fails + 1, 10 * MINUTE_IN_SECONDS );
wp_send_json_error( ["message" => "Ungültiger Code. Bitte erneut versuchen."] );
}
delete_transient( $ip_key );
unset( $_SESSION[ WBF_TOTP::SESSION_PENDING ] );
if ( WBF_Roles::level($user->role) < 0 ) {
wp_send_json_error( ["message" => "Dein Konto ist gesperrt."] );
}
if ( session_id() ) session_regenerate_id( true );
$_SESSION[ WBF_Auth::SESSION_KEY ] = $user->id;
WBF_DB::touch_last_active( $user->id );
if ( ! empty( $_SESSION["wbf_2fa_remember"] ) ) {
WBF_Auth::set_remember_cookie( $user->id );
unset( $_SESSION["wbf_2fa_remember"] );
}
wp_send_json_success( [
"display_name" => $user->display_name,
"avatar_url" => $user->avatar_url,
"user_id" => $user->id,
] );
}
}
add_action( 'init', [ 'WBF_Ajax', 'init' ] );

View File

@@ -11,8 +11,6 @@ class WBF_Auth {
// Lösung: headers_sent() prüfen + session_start() mit Cookie-Optionen aufrufen.
if ( ! session_id() ) {
if ( headers_sent() ) {
// Headers bereits gesendet — Session kann nicht sicher gestartet werden.
// Passiert z.B. wenn WP_DEBUG=true und PHP Notices vor dem Hook ausgegeben hat.
return;
}
$session_opts = [
@@ -20,7 +18,6 @@ class WBF_Auth {
'cookie_samesite' => 'Lax',
'use_strict_mode' => true,
];
// cookie_secure nur setzen wenn HTTPS aktiv — verhindert Session-Verlust bei HTTP
if ( is_ssl() || ( ! empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ) {
$session_opts['cookie_secure'] = true;
}
@@ -50,7 +47,7 @@ class WBF_Auth {
return WBF_DB::get_user( (int) $_SESSION[ self::SESSION_KEY ] );
}
public static function login( $username_or_email, $password ) {
public static function login( $username_or_email, $password, $remember = false ) {
self::init();
$user = WBF_DB::get_user_by( 'username', $username_or_email );
if ( ! $user ) {
@@ -60,6 +57,19 @@ class WBF_Auth {
if ( ! password_verify( $password, $user->password ) ) {
return array( 'success' => false, 'message' => 'Falsches Passwort.' );
}
// ── 2FA-Check ─────────────────────────────────────────────────────────
// Wenn 2FA aktiv: Login pausieren und TOTP-Code anfordern.
// remember-Flag in Session merken, damit es nach 2FA-Verifikation gesetzt wird.
if ( class_exists('WBF_TOTP') && WBF_TOTP::is_enabled_for( $user->id ) ) {
$_SESSION[ WBF_TOTP::SESSION_PENDING ] = $user->id;
if ( $remember ) {
$_SESSION['wbf_2fa_remember'] = true;
}
return array( 'success' => false, '2fa_required' => true );
}
// ── Ende 2FA-Check ────────────────────────────────────────────────────
if ( WBF_Roles::level($user->role) < 0 ) {
// Zeitlich begrenzte Sperre prüfen — automatisch aufheben wenn abgelaufen
if ( ! empty($user->ban_until) && strtotime($user->ban_until) <= time() ) {
@@ -70,22 +80,20 @@ class WBF_Auth {
'ban_until' => null,
'pre_ban_role' => '',
]);
// Frisch laden und einloggen
$user = WBF_DB::get_user( $user->id );
if ( session_id() ) session_regenerate_id( true ); // Session Fixation verhindern
if ( session_id() ) session_regenerate_id( true );
$_SESSION[ self::SESSION_KEY ] = $user->id;
WBF_DB::touch_last_active( $user->id );
return array( 'success' => true, 'user' => $user );
}
$reason = !empty($user->ban_reason) ? $user->ban_reason : 'Dein Konto wurde gesperrt.';
// Zeitstempel anhängen wenn temporäre Sperre
if ( ! empty($user->ban_until) ) {
$until_fmt = date_i18n( 'd.m.Y \u\m H:i \U\h\r', strtotime($user->ban_until) );
$reason .= ' (Gesperrt bis: ' . $until_fmt . ')';
}
return array( 'success' => false, 'banned' => true, 'message' => $reason );
}
if ( session_id() ) session_regenerate_id( true ); // Session Fixation verhindern
if ( session_id() ) session_regenerate_id( true );
$_SESSION[ self::SESSION_KEY ] = $user->id;
WBF_DB::touch_last_active( $user->id );
return array( 'success' => true, 'user' => $user );
@@ -115,7 +123,7 @@ class WBF_Auth {
'avatar_url' => $avatar,
));
if ( session_id() ) session_regenerate_id( true ); // Session Fixation verhindern
if ( session_id() ) session_regenerate_id( true );
$_SESSION[ self::SESSION_KEY ] = $id;
return array('success'=>true,'user'=>WBF_DB::get_user($id));
}
@@ -124,10 +132,14 @@ class WBF_Auth {
self::init();
$user_id = $_SESSION[ self::SESSION_KEY ] ?? 0;
unset( $_SESSION[ self::SESSION_KEY ] );
// 2FA-Pending-State ebenfalls löschen
if ( class_exists('WBF_TOTP') ) {
unset( $_SESSION[ WBF_TOTP::SESSION_PENDING ] );
unset( $_SESSION['wbf_2fa_remember'] );
}
if ( $user_id ) {
WBF_DB::delete_remember_token( (int)$user_id );
}
// Remove cookie
if ( isset($_COOKIE['wbf_remember']) ) {
setcookie( 'wbf_remember', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
}

View File

@@ -123,12 +123,23 @@ class WBF_BBCode {
$s
);
// [size=small|large|xlarge]
// [size=small|large|xlarge] oder [size=17] (klassisches BBCode)
$s = preg_replace_callback(
'/\[size=(small|large|xlarge)\](.*?)\[\/size\]/is',
'/\[size=([a-zA-Z0-9]+)\](.*?)\[\/size\]/is',
function ( $m ) {
$map = [ 'small' => '.8em', 'large' => '1.2em', 'xlarge' => '1.5em' ];
return '<span style="font-size:' . $map[$m[1]] . '">' . $m[2] . '</span>';
$val = strtolower( $m[1] );
// Benannte Größen
$named = [ 'small' => '.8em', 'large' => '1.2em', 'xlarge' => '1.5em' ];
if ( isset( $named[ $val ] ) ) {
$size = $named[ $val ];
// Numerische Größen 17 (klassisches BBCode-Schema)
} elseif ( ctype_digit( $val ) && (int)$val >= 1 && (int)$val <= 7 ) {
$num_map = [ 1 => '.7em', 2 => '.85em', 3 => '1em', 4 => '1.2em', 5 => '1.4em', 6 => '1.6em', 7 => '2em' ];
$size = $num_map[ (int)$val ];
} else {
return $m[2]; // Unbekannter Wert → nur Text
}
return '<span style="font-size:' . $size . '">' . $m[2] . '</span>';
},
$s
);

View File

@@ -151,7 +151,6 @@ class WBF_DB {
dbDelta( $sql_threads );
dbDelta( $sql_posts );
dbDelta( $sql_likes );
dbDelta( $sql_reports );
dbDelta( $sql_tags );
dbDelta( $sql_thread_tags );
dbDelta( $sql_messages );
@@ -174,6 +173,8 @@ class WBF_DB {
// Zeitlich begrenzte Sperren
self::maybe_add_column("{$wpdb->prefix}forum_users", 'ban_until', "ALTER TABLE {$wpdb->prefix}forum_users ADD COLUMN ban_until DATETIME DEFAULT NULL");
self::maybe_add_column("{$wpdb->prefix}forum_users", 'pre_ban_role', "ALTER TABLE {$wpdb->prefix}forum_users ADD COLUMN pre_ban_role VARCHAR(20) DEFAULT 'member'");
// Profilbanner
self::maybe_add_column("{$wpdb->prefix}forum_users", 'banner_url', "ALTER TABLE {$wpdb->prefix}forum_users ADD COLUMN banner_url VARCHAR(255) DEFAULT ''");
// Thread-Abonnements
$sql_subscriptions = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}forum_subscriptions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -199,7 +200,6 @@ class WBF_DB {
) $charset;";
// Ensure reports + notifications tables exist on existing installs
dbDelta( $sql_reports );
dbDelta( $sql_notifications );
// Einladungs-Tabelle
@@ -354,6 +354,67 @@ class WBF_DB {
public static function update_user( $id, $data ) {
global $wpdb;
$wpdb->update("{$wpdb->prefix}forum_users", $data, ['id' => $id]);
// --- Discord-Rollen-Sync nach Rollenänderung ---
if (isset($data['role'])) {
// Discord-User-ID holen
$discord_user_id = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$wpdb->prefix}forum_user_meta WHERE user_id = %d AND meta_key = 'discord_user_id'",
$id
));
if ($discord_user_id) {
// Einstellungen laden
$s = function_exists('wbf_get_settings') ? wbf_get_settings() : [];
$token = trim($s['discord_bot_token'] ?? '');
$guild = trim($s['discord_guild_id'] ?? '');
$role_map = json_decode($s['discord_role_map'] ?? '{}', true) ?: [];
if ($token && $guild && !empty($role_map)) {
// Ziel-Discord-Rolle anhand Mapping finden
$target_discord_role = null;
foreach ($role_map as $dc_role_id => $forum_role) {
if ($forum_role === $data['role']) {
$target_discord_role = (string)$dc_role_id;
break;
}
}
if ($target_discord_role) {
// Aktuelle Rollen des Users abrufen
$res = wp_remote_get("https://discord.com/api/v10/guilds/{$guild}/members/{$discord_user_id}", [
'timeout' => 6,
'headers' => ['Authorization' => 'Bot ' . $token],
]);
if (!is_wp_error($res) && wp_remote_retrieve_response_code($res) === 200) {
$member = json_decode(wp_remote_retrieve_body($res), true);
$user_roles = $member['roles'] ?? [];
// Alle gemappten Discord-Rollen entfernen, außer Zielrolle
$remove_roles = [];
foreach ($role_map as $dc_role_id => $forum_role) {
if ((string)$dc_role_id !== $target_discord_role && in_array((string)$dc_role_id, $user_roles, true)) {
$remove_roles[] = (string)$dc_role_id;
}
}
// Zielrolle hinzufügen, falls nicht vorhanden
if (!in_array($target_discord_role, $user_roles, true)) {
$user_roles[] = $target_discord_role;
}
// Entfernte Rollen rausnehmen
$user_roles = array_values(array_diff($user_roles, $remove_roles));
// PATCH an Discord senden
$body = json_encode(['roles' => array_values($user_roles)]);
wp_remote_request("https://discord.com/api/v10/guilds/{$guild}/members/{$discord_user_id}", [
'method' => 'PATCH',
'timeout' => 6,
'headers' => [
'Authorization' => 'Bot ' . $token,
'Content-Type' => 'application/json',
],
'body' => $body,
]);
}
}
}
}
}
}
public static function get_all_users( $limit = 100, $offset = 0 ) {
@@ -541,10 +602,24 @@ class WBF_DB {
) );
}
}
// Posts zählen, User-IDs sammeln
$posts = $wpdb->get_results($wpdb->prepare("SELECT user_id FROM {$wpdb->prefix}forum_posts WHERE thread_id=%d", $id));
$post_count = count($posts);
$user_post_counts = [];
foreach ($posts as $p) {
$uid = (int)$p->user_id;
if (!isset($user_post_counts[$uid])) $user_post_counts[$uid] = 0;
$user_post_counts[$uid]++;
}
// Posts löschen
$wpdb->delete("{$wpdb->prefix}forum_posts", ['thread_id' => $id]);
$wpdb->delete("{$wpdb->prefix}forum_threads", ['id' => $id]);
// Zähler anpassen
if ( $thread->status !== 'archived' ) {
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_categories SET thread_count=GREATEST(thread_count-1,0) WHERE id=%d", $thread->category_id));
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_categories SET thread_count=GREATEST(thread_count-1,0), post_count=GREATEST(post_count-%d,0) WHERE id=%d", $post_count, $thread->category_id));
}
foreach ($user_post_counts as $uid => $cnt) {
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_users SET post_count=GREATEST(post_count-%d,0) WHERE id=%d", $cnt, $uid));
}
}
@@ -796,6 +871,8 @@ class WBF_DB {
'object_id' => $object_id,
'actor_id' => $actor_id,
] );
// MC Bridge: Ingame-Benachrichtigung auslösen wenn Spieler verknüpft ist
do_action( 'wbf_notification_created', $user_id, $type, $object_id, $actor_id );
}
public static function get_notifications( $user_id, $limit = 20 ) {
@@ -1190,12 +1267,13 @@ class WBF_DB {
public static function create_remember_token( $user_id ) {
global $wpdb;
$token = bin2hex( random_bytes(32) );
$token_hash = hash('sha256', $token);
$expires = date('Y-m-d H:i:s', strtotime('+30 days'));
// Delete existing tokens for this user first
$wpdb->delete( "{$wpdb->prefix}forum_remember_tokens", ['user_id' => $user_id] );
$wpdb->insert( "{$wpdb->prefix}forum_remember_tokens", [
'user_id' => $user_id,
'token' => $token,
'token' => $token_hash,
'expires_at' => $expires,
] );
return $token;
@@ -1205,10 +1283,11 @@ class WBF_DB {
global $wpdb;
$table = "{$wpdb->prefix}forum_remember_tokens";
if ( $wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table ) return null;
$token_hash = hash('sha256', sanitize_text_field($token));
return $wpdb->get_row( $wpdb->prepare(
"SELECT user_id FROM {$wpdb->prefix}forum_remember_tokens
WHERE token=%s AND expires_at > NOW()",
sanitize_text_field($token)
$token_hash
) );
}
@@ -1397,11 +1476,25 @@ class WBF_DB {
public static function soft_delete_post( $post_id ) {
global $wpdb;
// Soft-Delete setzen
$wpdb->update(
"{$wpdb->prefix}forum_posts",
['deleted_at' => current_time('mysql')],
['id' => (int)$post_id]
);
// Zähler anpassen
$post = $wpdb->get_row($wpdb->prepare("SELECT thread_id, user_id FROM {$wpdb->prefix}forum_posts WHERE id=%d", $post_id));
if ($post) {
// Thread reply_count -1
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_threads SET reply_count=GREATEST(reply_count-1,0) WHERE id=%d", $post->thread_id));
// User post_count -1
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_users SET post_count=GREATEST(post_count-1,0) WHERE id=%d", $post->user_id));
// Kategorie post_count -1
$cat_id = $wpdb->get_var($wpdb->prepare("SELECT category_id FROM {$wpdb->prefix}forum_threads WHERE id=%d", $post->thread_id));
if ($cat_id) {
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}forum_categories SET post_count=GREATEST(post_count-1,0) WHERE id=%d", $cat_id));
}
}
}
public static function restore_thread( $thread_id ) {
@@ -1524,6 +1617,18 @@ class WBF_DB {
return $out;
}
/**
* Gibt einen einzelnen Meta-Wert zurück (oder leeren String wenn nicht vorhanden).
*/
public static function get_user_meta_single( $user_id, $key ) {
global $wpdb;
$value = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_value FROM {$wpdb->prefix}forum_user_meta WHERE user_id = %d AND meta_key = %s LIMIT 1",
(int) $user_id, $key
) );
return $value !== null ? $value : '';
}
public static function set_user_meta( $user_id, $key, $value ) {
global $wpdb;
$wpdb->replace(

View File

@@ -130,7 +130,7 @@ class WBF_Export {
case 'users':
$data['users'] = $wpdb->get_results(
"SELECT id, username, email, password, display_name, avatar_url,
"SELECT id, username, email, password, display_name, avatar_url, banner_url,
bio, signature, role, pre_ban_role, ban_reason, ban_until,
post_count, registered, last_active, profile_public,
reset_token, reset_token_expires

View File

@@ -0,0 +1,480 @@
<?php
/**
* WBF_MC_Bridge — Minecraft ↔ Forum Verknüpfung & Ingame-Benachrichtigungen
*
* Dieses Modul verbindet das WP Business Forum mit dem BungeeCord StatusAPI Plugin.
*
* Features:
* - Account-Verknüpfung: Forum-User ↔ MC-UUID (über Token-System)
* - Push-Benachrichtigungen: Neue Antwort/Erwähnung/PN → Ingame-Nachricht
* - REST API Endpoints für die BungeeCord-Seite
*
* Einbindung in wp-business-forum.php:
* require_once WBF_PATH . 'includes/class-forum-mc-bridge.php';
*
* Konfiguration in WBF-Einstellungen (Admin → Forum → Einstellungen):
* mc_bridge_enabled = true/false
* mc_bridge_api_url = http://server-ip:9191 (StatusAPI URL)
* mc_bridge_api_secret = Shared Secret für API-Authentifizierung
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class WBF_MC_Bridge {
/** Meta-Keys in forum_user_meta */
const META_MC_UUID = 'mc_uuid';
const META_MC_NAME = 'mc_name';
const META_LINK_TOKEN = 'mc_link_token';
const META_LINK_EXPIRY = 'mc_link_token_expires';
/**
* Hooks registrieren — wird beim Plugin-Laden aufgerufen.
*/
public static function init() {
// Hook: Wird in der modifizierten WBF_DB::create_notification() gefeuert
add_action( 'wbf_notification_created', [ __CLASS__, 'on_notification' ], 10, 4 );
// REST API Endpoints für BungeeCord
add_action( 'rest_api_init', [ __CLASS__, 'register_rest_routes' ] );
// AJAX: Token generieren (für eingeloggte Forum-User)
add_action( 'wp_ajax_wbf_mc_generate_token', [ __CLASS__, 'ajax_generate_token' ] );
add_action( 'wp_ajax_nopriv_wbf_mc_generate_token', [ __CLASS__, 'ajax_generate_token' ] );
// AJAX: Verknüpfung lösen
add_action( 'wp_ajax_wbf_mc_unlink', [ __CLASS__, 'ajax_unlink' ] );
add_action( 'wp_ajax_nopriv_wbf_mc_unlink', [ __CLASS__, 'ajax_unlink' ] );
// AJAX: Link-Status prüfen (Polling im Profil nach Token-Generierung)
add_action( 'wp_ajax_wbf_mc_link_status', [ __CLASS__, 'ajax_link_status' ] );
add_action( 'wp_ajax_nopriv_wbf_mc_link_status', [ __CLASS__, 'ajax_link_status' ] );
}
// ══════════════════════════════════════════════════════════════════════════
// ── Einstellungen ─────────────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Prüft ob die MC-Bridge aktiviert ist.
*/
public static function is_enabled() {
$s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : [];
return ! empty( $s['mc_bridge_enabled'] );
}
/**
* Gibt die StatusAPI-URL zurück (z.B. http://192.168.1.100:9191).
*/
private static function get_api_url() {
$s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : [];
return rtrim( $s['mc_bridge_api_url'] ?? '', '/' );
}
/**
* Gibt das Shared Secret zurück.
*/
private static function get_api_secret() {
$s = function_exists( 'wbf_get_settings' ) ? wbf_get_settings() : [];
return $s['mc_bridge_api_secret'] ?? '';
}
// ══════════════════════════════════════════════════════════════════════════
// ── Notification Hook ─────────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Wird aufgerufen wenn eine Forum-Notification erstellt wird.
* Prüft ob der Empfänger eine MC-UUID hat und pusht die Nachricht an BungeeCord.
*
* @param int $user_id Forum-User ID des Empfängers
* @param string $type Typ: 'reply', 'mention', 'message'
* @param int $object_id Thread-ID (bei reply/mention) oder Message-ID (bei message)
* @param int $actor_id Forum-User ID des Auslösers
*/
public static function on_notification( $user_id, $type, $object_id, $actor_id ) {
if ( ! self::is_enabled() ) return;
$api_url = self::get_api_url();
if ( empty( $api_url ) ) return;
// MC-UUID des Empfängers prüfen
$mc_uuid = WBF_DB::get_user_meta_single( $user_id, self::META_MC_UUID );
if ( empty( $mc_uuid ) ) return;
// Actor-Info laden
$actor = WBF_DB::get_user( (int) $actor_id );
$actor_name = $actor ? $actor->display_name : 'Unbekannt';
// Kontext-Daten sammeln
$title = '';
$url = '';
$forum_url = wbf_get_forum_url();
switch ( $type ) {
case 'reply':
case 'mention':
$thread = WBF_DB::get_thread( (int) $object_id );
if ( $thread ) {
$title = $thread->title;
$url = $forum_url . '?forum_thread=' . (int) $thread->id;
}
break;
case 'message':
$title = 'Neue Privatnachricht';
$url = $forum_url . '?forum_dm=1';
break;
}
// Push an BungeeCord senden
self::push_to_bungee( $mc_uuid, $type, $title, $actor_name, $url, $user_id );
}
/**
* Sendet die Benachrichtigung per HTTP POST an den BungeeCord StatusAPI Server.
*/
private static function push_to_bungee( $mc_uuid, $type, $title, $author, $url, $wp_user_id ) {
$api_url = self::get_api_url();
$secret = self::get_api_secret();
$payload = wp_json_encode( [
'player_uuid' => $mc_uuid,
'type' => $type,
'title' => $title,
'author' => $author,
'url' => $url,
'wp_user_id' => (int) $wp_user_id,
] );
$args = [
'method' => 'POST',
'timeout' => 5,
'blocking' => false, // Non-blocking — Seite wartet nicht auf Antwort
'headers' => [
'Content-Type' => 'application/json; charset=UTF-8',
'X-Api-Key' => $secret,
],
'body' => $payload,
'sslverify' => false, // Lokales Netzwerk braucht kein SSL
];
wp_remote_post( $api_url . '/forum/notify', $args );
}
// ══════════════════════════════════════════════════════════════════════════
// ── REST API (für BungeeCord → WordPress) ─────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
public static function register_rest_routes() {
// POST /wp-json/mc-bridge/v1/verify-link
// BungeeCord schickt Token + MC-UUID → WP verifiziert und speichert
register_rest_route( 'mc-bridge/v1', '/verify-link', [
'methods' => 'POST',
'callback' => [ __CLASS__, 'rest_verify_link' ],
'permission_callback' => '__return_true',
] );
// POST /wp-json/mc-bridge/v1/unlink
// BungeeCord kann Verknüpfung auch von der MC-Seite lösen
register_rest_route( 'mc-bridge/v1', '/unlink', [
'methods' => 'POST',
'callback' => [ __CLASS__, 'rest_unlink' ],
'permission_callback' => '__return_true',
] );
// GET /wp-json/mc-bridge/v1/status
// Verbindungstest
register_rest_route( 'mc-bridge/v1', '/status', [
'methods' => 'GET',
'callback' => [ __CLASS__, 'rest_status' ],
'permission_callback' => '__return_true',
] );
}
/**
* REST: Verknüpfung bestätigen.
* BungeeCord sendet: { "token": "...", "mc_uuid": "...", "mc_name": "..." }
*/
public static function rest_verify_link( $request ) {
// Rate Limiting: max 10 Versuche pro IP pro Minute
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$limit_key = 'wbf_mc_link_' . md5($ip);
$attempts = (int) get_transient($limit_key);
if ($attempts >= 10) {
return new WP_REST_Response([
'success' => false,
'error' => 'rate_limited',
'message' => 'Zu viele Versuche. Bitte warte eine Minute.'
], 429);
}
set_transient($limit_key, $attempts + 1, 60);
// API-Secret prüfen
$secret = self::get_api_secret();
if ( ! empty( $secret ) ) {
$provided = $request->get_header( 'X-Api-Key' );
if ( $provided !== $secret ) {
return new WP_REST_Response( [ 'success' => false, 'error' => 'unauthorized' ], 403 );
}
}
$token = sanitize_text_field( $request->get_param( 'token' ) );
$mc_uuid = sanitize_text_field( $request->get_param( 'mc_uuid' ) );
$mc_name = sanitize_text_field( $request->get_param( 'mc_name' ) );
if ( empty( $token ) || empty( $mc_uuid ) ) {
return new WP_REST_Response( [ 'success' => false, 'error' => 'missing_fields' ], 400 );
}
// Token in forum_user_meta suchen
global $wpdb;
$meta_row = $wpdb->get_row( $wpdb->prepare(
"SELECT user_id FROM {$wpdb->prefix}forum_user_meta WHERE meta_key = %s AND meta_value = %s",
self::META_LINK_TOKEN, $token
) );
if ( ! $meta_row ) {
return new WP_REST_Response( [ 'success' => false, 'error' => 'invalid_token' ], 404 );
}
$forum_user_id = (int) $meta_row->user_id;
// Ablauf prüfen
$expiry_meta = WBF_DB::get_user_meta( $forum_user_id );
$expiry = $expiry_meta[ self::META_LINK_EXPIRY ] ?? '0';
if ( (int) $expiry < time() ) {
// Token abgelaufen — aufräumen
WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_TOKEN, '' );
WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_EXPIRY, '' );
return new WP_REST_Response( [ 'success' => false, 'error' => 'token_expired' ], 410 );
}
// Prüfen ob diese MC-UUID bereits mit einem anderen Account verknüpft ist
$existing = $wpdb->get_row( $wpdb->prepare(
"SELECT user_id FROM {$wpdb->prefix}forum_user_meta WHERE meta_key = %s AND meta_value = %s",
self::META_MC_UUID, $mc_uuid
) );
if ( $existing && (int) $existing->user_id !== $forum_user_id ) {
return new WP_REST_Response( [
'success' => false,
'error' => 'uuid_already_linked',
'message' => 'Diese Minecraft-UUID ist bereits mit einem anderen Forum-Account verknüpft.',
], 409 );
}
// Verknüpfung speichern
WBF_DB::set_user_meta( $forum_user_id, self::META_MC_UUID, $mc_uuid );
WBF_DB::set_user_meta( $forum_user_id, self::META_MC_NAME, $mc_name );
// Token aufräumen
WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_TOKEN, '' );
WBF_DB::set_user_meta( $forum_user_id, self::META_LINK_EXPIRY, '' );
// Forum-User-Info für BungeeCord zurückgeben
$forum_user = WBF_DB::get_user( $forum_user_id );
return new WP_REST_Response( [
'success' => true,
'forum_user_id' => $forum_user_id,
'display_name' => $forum_user ? $forum_user->display_name : '',
'username' => $forum_user ? $forum_user->username : '',
], 200 );
}
/**
* REST: Verknüpfung lösen (von BungeeCord-Seite).
*/
public static function rest_unlink( $request ) {
$secret = self::get_api_secret();
if ( ! empty( $secret ) ) {
$provided = $request->get_header( 'X-Api-Key' );
if ( $provided !== $secret ) {
return new WP_REST_Response( [ 'success' => false, 'error' => 'unauthorized' ], 403 );
}
}
$mc_uuid = sanitize_text_field( $request->get_param( 'mc_uuid' ) );
if ( empty( $mc_uuid ) ) {
return new WP_REST_Response( [ 'success' => false, 'error' => 'missing_mc_uuid' ], 400 );
}
global $wpdb;
$meta_row = $wpdb->get_row( $wpdb->prepare(
"SELECT user_id FROM {$wpdb->prefix}forum_user_meta WHERE meta_key = %s AND meta_value = %s",
self::META_MC_UUID, $mc_uuid
) );
if ( ! $meta_row ) {
return new WP_REST_Response( [ 'success' => false, 'error' => 'not_linked' ], 404 );
}
$forum_user_id = (int) $meta_row->user_id;
WBF_DB::set_user_meta( $forum_user_id, self::META_MC_UUID, '' );
WBF_DB::set_user_meta( $forum_user_id, self::META_MC_NAME, '' );
return new WP_REST_Response( [ 'success' => true ], 200 );
}
/**
* REST: Status-Endpoint für Verbindungstest.
*/
public static function rest_status( $request ) {
return new WP_REST_Response( [
'success' => true,
'enabled' => self::is_enabled(),
'version' => defined( 'WBF_VERSION' ) ? WBF_VERSION : '?',
'plugin' => 'WP Business Forum',
], 200 );
}
// ══════════════════════════════════════════════════════════════════════════
// ── AJAX: Token generieren ────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Generiert einen 8-stelligen Verknüpfungs-Token (15 Minuten gültig).
* Der User gibt diesen Token dann ingame mit /forumlink <token> ein.
*/
public static function ajax_generate_token() {
check_ajax_referer( 'wbf_nonce', 'nonce' );
$user = WBF_Auth::get_current_user();
if ( ! $user ) {
wp_send_json_error( [ 'message' => 'Nicht eingeloggt.' ] );
}
// Prüfen ob bereits verknüpft
$existing_uuid = WBF_DB::get_user_meta_single( $user->id, self::META_MC_UUID );
if ( ! empty( $existing_uuid ) ) {
$mc_name = WBF_DB::get_user_meta_single( $user->id, self::META_MC_NAME );
wp_send_json_error( [
'message' => 'Dein Account ist bereits mit ' . esc_html( $mc_name ?: $existing_uuid ) . ' verknüpft.',
'linked' => true,
'mc_name' => $mc_name,
'mc_uuid' => $existing_uuid,
] );
}
// Token generieren: 8 Zeichen, alphanumerisch, uppercase
$token = strtoupper( substr( bin2hex( random_bytes( 5 ) ), 0, 8 ) );
$expiry = time() + ( 15 * 60 ); // 15 Minuten
WBF_DB::set_user_meta( $user->id, self::META_LINK_TOKEN, $token );
WBF_DB::set_user_meta( $user->id, self::META_LINK_EXPIRY, (string) $expiry );
wp_send_json_success( [
'token' => $token,
'expires_in' => 15, // Minuten
'command' => '/forumlink ' . $token,
] );
}
/**
* AJAX: Verknüpfung lösen (von der Forum-Seite).
*/
public static function ajax_unlink() {
check_ajax_referer( 'wbf_nonce', 'nonce' );
$user = WBF_Auth::get_current_user();
if ( ! $user ) {
wp_send_json_error( [ 'message' => 'Nicht eingeloggt.' ] );
}
$mc_uuid = WBF_DB::get_user_meta_single( $user->id, self::META_MC_UUID );
// Meta löschen
WBF_DB::set_user_meta( $user->id, self::META_MC_UUID, '' );
WBF_DB::set_user_meta( $user->id, self::META_MC_NAME, '' );
WBF_DB::set_user_meta( $user->id, self::META_LINK_TOKEN, '' );
WBF_DB::set_user_meta( $user->id, self::META_LINK_EXPIRY, '' );
// Optional: BungeeCord informieren
if ( ! empty( $mc_uuid ) && self::is_enabled() ) {
$api_url = self::get_api_url();
$secret = self::get_api_secret();
if ( ! empty( $api_url ) ) {
wp_remote_post( $api_url . '/forum/unlink', [
'method' => 'POST',
'timeout' => 3,
'blocking' => false,
'headers' => [
'Content-Type' => 'application/json',
'X-Api-Key' => $secret,
],
'body' => wp_json_encode( [ 'mc_uuid' => $mc_uuid ] ),
'sslverify' => false,
] );
}
}
wp_send_json_success( [ 'message' => 'Minecraft-Verknüpfung wurde aufgehoben.' ] );
}
/**
* AJAX: Verknüpfungs-Status prüfen.
* Wird vom Frontend-Polling alle 5 Sekunden nach Token-Generierung aufgerufen.
* Gibt zurück ob der User bereits verknüpft ist (BungeeCord hat verify-link gesendet).
*/
public static function ajax_link_status() {
check_ajax_referer( 'wbf_nonce', 'nonce' );
$user = WBF_Auth::get_current_user();
if ( ! $user ) {
wp_send_json_error( [ 'message' => 'Nicht eingeloggt.', 'linked' => false ] );
return;
}
$mc_uuid = WBF_DB::get_user_meta_single( $user->id, self::META_MC_UUID );
$mc_name = WBF_DB::get_user_meta_single( $user->id, self::META_MC_NAME );
if ( ! empty( $mc_uuid ) ) {
wp_send_json_success( [
'linked' => true,
'mc_uuid' => $mc_uuid,
'mc_name' => $mc_name,
] );
} else {
wp_send_json_success( [ 'linked' => false ] );
}
}
// ══════════════════════════════════════════════════════════════════════════
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════
/**
* Gibt die MC-UUID eines Forum-Users zurück (oder leer).
*/
public static function get_mc_uuid( $forum_user_id ) {
return WBF_DB::get_user_meta_single( $forum_user_id, self::META_MC_UUID );
}
/**
* Gibt den MC-Namen eines Forum-Users zurück (oder leer).
*/
public static function get_mc_name( $forum_user_id ) {
return WBF_DB::get_user_meta_single( $forum_user_id, self::META_MC_NAME );
}
/**
* Prüft ob ein Forum-User mit MC verknüpft ist.
*/
public static function is_linked( $forum_user_id ) {
$uuid = self::get_mc_uuid( $forum_user_id );
return ! empty( $uuid );
}
/**
* HTML-Badge für Profilansicht: Zeigt MC-Verknüpfung an.
*/
public static function profile_badge( $forum_user_id ) {
if ( ! self::is_enabled() ) return '';
$mc_name = self::get_mc_name( $forum_user_id );
if ( empty( $mc_name ) ) return '';
$name_esc = esc_html( $mc_name );
$head_url = 'https://mc-heads.net/avatar/' . urlencode( $mc_name ) . '/24';
return "<span class=\"wbf-mc-badge\" title=\"Minecraft: {$name_esc}\">"
. "<img src=\"{$head_url}\" alt=\"\" width=\"16\" height=\"16\" style=\"border-radius:2px;vertical-align:middle;margin-right:4px\">"
. "<span style=\"color:#55ff55\">{$name_esc}</span>"
. "</span>";
}
}
// Initialisierung
add_action( 'init', [ 'WBF_MC_Bridge', 'init' ], 20 );

View File

@@ -192,15 +192,32 @@ class WBF_Roles {
];
}
/** Ist der aktuelle WP-User der Seiteninhaber (Superadmin)? */
public static function is_wp_superadmin() {
return current_user_can('administrator') || (is_multisite() && is_super_admin());
/**
* Gibt die WP-User-ID des echten Superadmins zurück.
* Das ist immer der bei der WordPress-Installation angelegte erste Nutzer (ID 1).
* Alle anderen WP-Administratoren sind KEINE Forum-Superadmins.
*/
public static function get_wp_superadmin_id() {
// Primär: gespeicherte ID aus den Plugin-Einstellungen (falls manuell überschrieben)
$saved_id = (int) get_option( 'wbf_superadmin_wp_id', 0 );
if ( $saved_id > 0 ) return $saved_id;
// Fallback: WP-Installations-User (ID 1)
return 1;
}
/** Superadmin-Status erzwingen: Forum-User des WP-Admins immer auf superadmin setzen */
/** Ist der aktuelle eingeloggte WP-User der echte Superadmin (nur ID 1 bzw. gespeicherte ID)? */
public static function is_wp_superadmin() {
if ( ! is_user_logged_in() ) return false;
return get_current_user_id() === self::get_wp_superadmin_id();
}
/**
* Superadmin-Status erzwingen — aber NUR für den einen echten WP-Superadmin (ID 1).
* Alle anderen WP-Admins können normale Forum-Rollen haben und behalten diese auch.
*/
public static function sync_superadmin() {
if ( ! is_user_logged_in() ) return;
if ( ! self::is_wp_superadmin() ) return;
if ( ! self::is_wp_superadmin() ) return; // nur ID 1 kommt durch
$wp_user = wp_get_current_user();
$forum_user = WBF_DB::get_user_by( 'email', $wp_user->user_email );

View File

@@ -707,7 +707,7 @@ class WBF_Shortcodes {
</div>
</div>
<?php if (!empty($thread->signature)): ?>
<div class="wbf-signature"><div class="wbf-signature__divider"></div><?php echo nl2br(esc_html($thread->signature)); ?></div>
<div class="wbf-signature"><div class="wbf-signature__divider"></div><?php echo WBF_BBCode::render($thread->signature); ?></div>
<?php endif; ?>
<div class="wbf-post__footer">
<span class="wbf-post__date"><?php echo self::time_ago($thread->created_at); ?></span>
@@ -844,7 +844,7 @@ class WBF_Shortcodes {
</div>
</div>
<?php if (!empty($post->signature)): ?>
<div class="wbf-signature"><div class="wbf-signature__divider"></div><?php echo nl2br(esc_html($post->signature)); ?></div>
<div class="wbf-signature"><div class="wbf-signature__divider"></div><?php echo WBF_BBCode::render($post->signature); ?></div>
<?php endif; ?>
<div class="wbf-post__footer">
<span class="wbf-post__date"><?php echo self::time_ago($post->created_at); ?></span>
@@ -928,11 +928,18 @@ class WBF_Shortcodes {
// Aktiven Tab aus URL lesen (tab=1|2|3), Standard: 1 für eigenes, 2 für fremdes
// Tab-ID: numerisch (14) oder String-Slug (z.B. 'mc' von der Forum-Bridge)
$ptab_raw = $_GET['ptab'] ?? ($is_own ? 1 : 2);
$shop_active = class_exists('WIS_DB');
$shop_tab_id = 'shop';
$allowed_tabs = [1,2,3,4];
if ($is_own && $shop_active) $allowed_tabs[] = $shop_tab_id;
$active_tab = ctype_digit( (string) $ptab_raw ) ? (int) $ptab_raw : sanitize_key( $ptab_raw );
if (is_int($active_tab) && !in_array($active_tab, [1,2,3,4])) {
$active_tab = $is_own ? 1 : 2;
}
// Tab 1, 3, 4 und String-Tabs nur für eigenes Profil (außer Tab 2 = Aktivität)
if (!is_int($active_tab) && $active_tab !== $shop_tab_id && $active_tab !== 'mc') {
$active_tab = $is_own ? 1 : 2;
}
// Tab 1, 3, 4, "shop" und String-Tabs nur für eigenes Profil (außer Tab 2 = Aktivität)
if (!$is_own && $active_tab !== 2) $active_tab = 2;
ob_start(); ?>
@@ -948,6 +955,21 @@ class WBF_Shortcodes {
<!-- ── SIDEBAR ─────────────────────────────────────────── -->
<aside class="wbf-profile-sidebar">
<!-- Banner -->
<div class="wbf-profile-banner" id="wbfProfileBannerWrap">
<?php if ( ! empty($profile->banner_url) ) : ?>
<img src="<?php echo esc_url($profile->banner_url); ?>"
alt="" id="wbfProfileBanner" class="wbf-profile-banner__img">
<?php else : ?>
<div class="wbf-profile-banner__placeholder"></div>
<?php endif; ?>
<?php if ($is_own) : ?>
<label class="wbf-banner-upload-btn" title="Banner ändern">
<i class="fas fa-image"></i>
<input type="file" id="wbfBannerFile" accept="image/*" style="display:none">
</label>
<?php endif; ?>
</div>
<div class="wbf-profile-sidebar__avatar-wrap">
<img src="<?php echo esc_url($profile->avatar_url); ?>"
alt="<?php echo esc_attr($profile->display_name); ?>"
@@ -994,13 +1016,13 @@ class WBF_Shortcodes {
<?php if (!empty($profile->bio)): ?>
<div class="wbf-profile-sidebar__section">
<span class="wbf-profile-sidebar__section-label"><i class="fas fa-align-left"></i> Bio</span>
<p><?php echo nl2br(esc_html($profile->bio)); ?></p>
<div class="wbf-profile-sidebar__bio-text"><?php echo WBF_BBCode::render($profile->bio); ?></div>
</div>
<?php endif; ?>
<?php if (!empty($profile->signature)): ?>
<div class="wbf-profile-sidebar__section">
<span class="wbf-profile-sidebar__section-label"><i class="fas fa-pen-nib"></i> Signatur</span>
<p class="wbf-profile-sidebar__sig"><?php echo nl2br(esc_html($profile->signature)); ?></p>
<div class="wbf-profile-sidebar__sig"><?php echo WBF_BBCode::render($profile->signature); ?></div>
</div>
<?php endif; ?>
<!-- Öffentliche Custom Fields — nach Kategorie gruppiert -->
@@ -1113,12 +1135,131 @@ class WBF_Shortcodes {
class="wbf-profile-tab<?php echo $active_tab===4?' active':''; ?>">
<i class="fas fa-lock"></i> Sicherheit
</a>
<?php if ( class_exists('MC_Gallery_Forum_Bridge') ) : ?>
<a href="?forum_profile=<?php echo (int)$profile->id; ?>&ptab=mc"
class="wbf-profile-tab<?php echo $active_tab==='mc'?' active':''; ?>">
<i class="fas fa-cubes"></i> Minecraft
<?php if ($shop_active): ?>
<a href="?forum_profile=<?php echo (int)$profile->id; ?>&ptab=shop"
class="wbf-profile-tab<?php echo $active_tab==='shop'?' active':''; ?>">
<i class="fas fa-shopping-cart"></i> Käufe
</a>
<?php endif; ?>
<?php
// „Verbindungen" Tab — immer sichtbar (Discord eingebaut, MC optional)
$wbf_has_connections = true;
if ( $wbf_has_connections ) : ?>
<a href="?forum_profile=<?php echo (int)$profile->id; ?>&ptab=mc"
class="wbf-profile-tab<?php echo $active_tab==='mc'?' active':''; ?>">
<i class="fas fa-plug"></i> Verbindungen
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- ══════════════════════════════════════════════════
TAB SHOP — Käufe (Shop-Plugin)
══════════════════════════════════════════════════ -->
<?php if ($is_own && $active_tab === 'shop' && $shop_active): ?>
<div class="wbf-profile-card">
<div class="wbf-profile-card__header">
<i class="fas fa-shopping-cart"></i> Deine Käufe
</div>
<div class="wbf-profile-card__body">
<?php
$orders = [];
if (class_exists('WIS_DB')) {
global $wpdb;
$username = $profile->username;
$orders = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wis_orders WHERE player_name = %s ORDER BY created_at DESC",
$username
));
}
?>
<?php if (empty($orders)): ?>
<p class="wbf-profile-empty">Du hast noch keine Käufe getätigt.</p>
<?php else: ?>
<div class="wbf-shop-orders-list">
<table class="wbf-shop-orders-table">
<thead>
<tr>
<th style="text-align:left">Datum</th>
<th style="text-align:center">Anzahl</th>
<th style="text-align:right">Gesamtpreis</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $i => $order):
$is_cancelled = strtolower($order->status) === 'cancelled' || strtolower($order->status) === 'storniert';
$row_class = $is_cancelled ? 'wbf-shop-order-cancelled' : '';
?>
<tr class="wbf-shop-order-row <?php echo $row_class; ?>" data-idx="<?php echo $i; ?>">
<td><?php echo date_i18n('d.m.Y H:i', strtotime($order->created_at)); ?></td>
<td style="text-align:center"><?php echo (int)$order->quantity; ?></td>
<td style="text-align:right"><?php echo number_format($order->price * $order->quantity); ?> <?php echo esc_html(get_option('wis_currency_name', 'Coins')); ?></td>
<td style="text-align:center">
<button class="wbf-btn wbf-btn--sm wbf-shop-order-toggle" data-idx="<?php echo $i; ?>"><i class="fas fa-chevron-down"></i></button>
</td>
</tr>
<tr class="wbf-shop-order-details" id="wbf-shop-order-details-<?php echo $i; ?>" style="display:none">
<td colspan="4">
<div class="wbf-shop-order-details-inner">
<?php /* Artikel-Zeile entfernt, da Item-Liste folgt */ ?>
<strong>Einzelpreis:</strong> <?php echo number_format($order->price); ?> <?php echo esc_html(get_option('wis_currency_name', 'Coins')); ?><br>
<strong>Status:</strong> <?php echo esc_html(ucfirst($order->status)); ?><br>
<?php if (!empty($order->server)): ?><strong>Server:</strong> <?php echo esc_html($order->server); ?><br><?php endif; ?>
<?php
// Antwort als JSON-Items/Coupon anzeigen
$response = $order->response;
$decoded = null;
if (!empty($response)) {
$decoded = json_decode($response, true);
}
if (is_array($decoded) && isset($decoded['items'])) {
echo '<strong>Gekaufte Items:</strong><ul style="margin:.3em 0 .7em 1.2em">';
foreach ($decoded['items'] as $item) {
$item_id = isset($item['id']) ? $item['id'] : '';
$item_id = preg_replace('/^minecraft:/', '', $item_id);
$amount = isset($item['amount']) ? (int)$item['amount'] : 1;
echo '<li><span style="color:var(--c-primary)">' . esc_html($item_id) . '</span> <span style="color:var(--c-muted)">x' . $amount . '</span></li>';
}
echo '</ul>';
if (isset($decoded['coupon']['code'])) {
$c = $decoded['coupon'];
echo '<div style="margin:.2em 0 .5em 0"><strong>Coupon:</strong> <span style="color:var(--c-success)">' . esc_html($c['code']) . '</span>';
if (isset($c['discount'])) echo ' <span style="color:var(--c-muted)">(' . intval($c['discount']) . '% Rabatt)</span>';
echo '</div>';
}
} elseif (!empty($response)) {
echo '<strong>Antwort:</strong> ' . esc_html($response) . '<br>';
}
?>
<span style="font-size:.85em;color:var(--c-muted)">Bestell-ID: <?php echo (int)$order->id; ?></span>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.wbf-shop-order-toggle').forEach(function(btn) {
btn.addEventListener('click', function() {
var idx = btn.getAttribute('data-idx');
var details = document.getElementById('wbf-shop-order-details-' + idx);
if (details.style.display === 'none') {
details.style.display = '';
btn.querySelector('i').classList.remove('fa-chevron-down');
btn.querySelector('i').classList.add('fa-chevron-up');
} else {
details.style.display = 'none';
btn.querySelector('i').classList.remove('fa-chevron-up');
btn.querySelector('i').classList.add('fa-chevron-down');
}
});
});
});
</script>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
@@ -1139,10 +1280,12 @@ class WBF_Shortcodes {
</div>
<div class="wbf-form-row">
<label>Bio</label>
<?php self::render_editor_toolbar('wbfEditBio'); ?>
<textarea id="wbfEditBio" rows="2"><?php echo esc_textarea($profile->bio); ?></textarea>
</div>
<div class="wbf-form-row">
<label>Signatur <small>(max. 300 Zeichen)</small></label>
<?php self::render_editor_toolbar('wbfEditSignature'); ?>
<textarea id="wbfEditSignature" rows="2" maxlength="300" placeholder="Deine Signatur…"><?php echo esc_textarea($profile->signature ?? ''); ?></textarea>
<div class="wbf-sig-counter"><span id="wbfSigCount"><?php echo mb_strlen($profile->signature??''); ?></span>/300</div>
</div>
@@ -1532,26 +1675,496 @@ class WBF_Shortcodes {
</div>
</div>
<!-- ══════════════════════════════════════════════════
2FA — Zwei-Faktor-Authentifizierung (TOTP)
══════════════════════════════════════════════════ -->
<?php if ( class_exists('WBF_TOTP') ) :
$wbf_2fa_active = WBF_TOTP::is_enabled_for($current->id);
?>
<div class="wbf-profile-card wbf-2fa-card" id="wbf2faCard">
<div class="wbf-profile-card__header" style="background:rgba(234,179,8,.07);border-bottom-color:rgba(234,179,8,.2)">
<i class="fas fa-shield-halved" style="color:#eab308"></i>
Zwei-Faktor-Authentifizierung (2FA)
<?php if ( $wbf_2fa_active ): ?>
<span class="wbf-2fa-badge wbf-2fa-badge--on"><i class="fas fa-check-circle"></i> Aktiv</span>
<?php else: ?>
<span class="wbf-2fa-badge wbf-2fa-badge--off"><i class="fas fa-circle-xmark"></i> Inaktiv</span>
<?php endif; ?>
</div>
<div class="wbf-profile-card__body">
<!-- ── 2FA bereits aktiv: Deaktivierungs-Formular ── -->
<?php if ( $wbf_2fa_active ): ?>
<div id="wbf2faActive">
<p style="color:var(--c-muted);font-size:.85rem;margin-bottom:1rem">
<i class="fas fa-info-circle"></i>
Dein Account ist mit einem Authenticator gesichert.
Zum Deaktivieren Passwort und aktuellen Code eingeben.
</p>
<div class="wbf-profile-edit-grid">
<div class="wbf-form-row">
<label>Aktuelles Passwort</label>
<input type="password" id="wbf2faDisablePw" placeholder="••••••" autocomplete="current-password">
</div>
<div class="wbf-form-row">
<label>Authenticator-Code</label>
<input type="text" id="wbf2faDisableCode" placeholder="123456"
maxlength="6" inputmode="numeric" autocomplete="one-time-code"
style="letter-spacing:.2em;font-size:1.15rem;font-family:monospace">
</div>
</div>
<div class="wbf-profile-card__footer">
<button class="wbf-btn" id="wbf2faDisableBtn"
style="background:rgba(220,38,38,.1);color:#dc2626;border-color:rgba(220,38,38,.3)">
<i class="fas fa-shield-xmark"></i> 2FA deaktivieren
</button>
<span class="wbf-msg" id="wbf2faDisableMsg"></span>
</div>
</div>
<?php else: ?>
<!-- ── 2FA noch nicht aktiv: Setup-Wizard ── -->
<div id="wbf2faInactive">
<p style="color:var(--c-muted);font-size:.85rem;margin-bottom:1rem">
<i class="fas fa-info-circle"></i>
Schütze deinen Account zusätzlich mit einer Authenticator-App
(Google Authenticator, Aegis, Bitwarden, Authy, 2FAS…).
</p>
<button class="wbf-btn wbf-btn--primary" id="wbf2faStartBtn">
<i class="fas fa-shield-halved"></i> 2FA einrichten
</button>
</div>
<!-- Schritt 1: QR-Code scannen -->
<div id="wbf2faStep1" style="display:none">
<p style="font-size:.85rem;color:var(--c-muted);margin-bottom:.75rem">
<strong>Schritt 1:</strong> Scanne diesen QR-Code mit deiner Authenticator-App.
</p>
<div id="wbf2faQr" style="display:inline-block;padding:10px;background:#fff;border-radius:8px;margin-bottom:.75rem"></div>
<p style="font-size:.8rem;color:var(--c-muted);margin-bottom:.5rem">
Kein QR-Scanner? Gib diesen Code manuell ein:
</p>
<code id="wbf2faSecret" style="font-size:.9rem;letter-spacing:.1em;background:var(--c-bg-2);padding:4px 10px;border-radius:4px;user-select:all"></code>
<div class="wbf-profile-card__footer" style="margin-top:1rem">
<button class="wbf-btn wbf-btn--primary" id="wbf2faToStep2">
Weiter <i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Schritt 2: Code bestätigen -->
<div id="wbf2faStep2" style="display:none">
<p style="font-size:.85rem;color:var(--c-muted);margin-bottom:.75rem">
<strong>Schritt 2:</strong> Gib den 6-stelligen Code aus deiner App ein.
</p>
<div class="wbf-form-row" style="max-width:220px">
<label>Bestätigungs-Code</label>
<input type="text" id="wbf2faVerifyCode" placeholder="123456"
maxlength="6" inputmode="numeric" autocomplete="one-time-code"
style="letter-spacing:.25em;font-size:1.3rem;text-align:center;font-family:monospace">
</div>
<div class="wbf-profile-card__footer">
<button class="wbf-btn" id="wbf2faBackBtn" style="opacity:.7">
<i class="fas fa-arrow-left"></i> Zurück
</button>
<button class="wbf-btn wbf-btn--primary" id="wbf2faVerifyBtn">
<i class="fas fa-check"></i> Bestätigen &amp; aktivieren
</button>
<span class="wbf-msg" id="wbf2faVerifyMsg"></span>
</div>
</div>
<!-- Schritt 3: Erfolg -->
<div id="wbf2faStep3" style="display:none;text-align:center;padding:1.5rem 0">
<div style="font-size:2.5rem;margin-bottom:.5rem">🔒</div>
<strong style="font-size:1rem;color:var(--c-text)">2FA erfolgreich aktiviert!</strong>
<p style="font-size:.85rem;color:var(--c-muted);margin:.5rem 0 0">
Ab jetzt wird beim Login ein Code aus deiner App abgefragt.
</p>
</div>
<?php endif; // 2fa_active ?>
</div>
</div>
<?php endif; // class_exists WBF_TOTP ?>
<?php endif; /* end Tab 4 */ ?>
<!-- ══════════════════════════════════════════════════
TAB MC — Minecraft-Konto verknüpfen (Bridge)
Wird nur gerendert wenn MC Gallery Forum Bridge aktiv ist.
TAB MC — Verbindungen (Externe Dienste verknüpfen)
Wird nur gerendert wenn mind. eine Integration aktiv ist.
Neue Integrationen: einfach weiteres .wbf-connection-card-Block
via apply_filters('wbf_profile_connections', ...) hinzufügen.
══════════════════════════════════════════════════ -->
<?php if ( $is_own && $active_tab === 'mc' && class_exists('MC_Gallery_Forum_Bridge') ) :
echo apply_filters('wbf_profile_tab_content', '', 'minecraft', $profile);
endif; /* end Tab MC */ ?>
<?php if ( $is_own && $active_tab === 'mc' ) : ?>
<div class="wbf-profile-card">
<div class="wbf-profile-card__header">
<i class="fas fa-plug"></i> Verbundene Dienste
</div>
<div class="wbf-profile-card__body wbf-connections-body">
<?php if ( class_exists('MC_Gallery_Forum_Bridge') ) :
$mc_content = apply_filters('wbf_profile_tab_content', '', 'minecraft', $profile);
?>
<div class="wbf-connection-card">
<div class="wbf-connection-card__icon" style="background:rgba(101,163,13,.15);border-color:rgba(101,163,13,.3)">
<i class="fas fa-cubes" style="color:#65a30d"></i>
</div>
<div class="wbf-connection-card__head">
<span class="wbf-connection-card__title">Gallerie Verbindung</span>
</div>
<div class="wbf-connection-card__content">
<?php echo $mc_content; ?>
</div>
</div>
<?php endif; ?>
<?php
// ── StatusAPI Bridge: Account-Verknüpfung & Ingame-Benachrichtigungen ──
$mc_enabled = class_exists( 'WBF_MC_Bridge' ) && WBF_MC_Bridge::is_enabled();
$mc_uuid = $mc_enabled ? WBF_MC_Bridge::get_mc_uuid( $profile->id ) : '';
$mc_name = $mc_enabled ? WBF_MC_Bridge::get_mc_name( $profile->id ) : '';
$mc_linked = ! empty( $mc_uuid );
?>
<div class="wbf-connection-card">
<div class="wbf-connection-card__icon" style="background:rgba(101,163,13,.15);border-color:rgba(101,163,13,.3)">
<i class="fas fa-cubes" style="color:#65a30d"></i>
</div>
<div class="wbf-connection-card__head">
<span class="wbf-connection-card__title">Minecraft InGame Verbindung</span>
<?php if ( ! $mc_enabled ) : ?>
<span class="wbf-connection-badge" style="color:#9ca3af;background:rgba(156,163,175,.1);border-color:rgba(156,163,175,.3)">
<i class="fas fa-circle-xmark"></i> Nicht konfiguriert
</span>
<?php elseif ( $mc_linked ) : ?>
<span class="wbf-connection-badge wbf-connection-badge--connected">
<i class="fas fa-check-circle"></i> Verbunden
</span>
<?php else : ?>
<span class="wbf-connection-badge wbf-connection-badge--disconnected">
<i class="fas fa-circle-xmark"></i> Nicht verbunden
</span>
<?php endif; ?>
</div>
<div class="wbf-connection-card__content">
<?php if ( ! $mc_enabled ) : ?>
<p class="wbf-connection-card__desc" style="color:var(--c-muted)">
<i class="fas fa-info-circle"></i>
Die Minecraft Bridge ist noch nicht eingerichtet.
Ein Admin muss sie zuerst in den Forum-Einstellungen aktivieren.
</p>
<?php elseif ( $mc_linked ) : ?>
<div class="wbf-mc-linked-info" style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
<img src="https://mc-heads.net/avatar/<?php echo urlencode( $mc_name ?: $mc_uuid ); ?>/40"
alt="" width="40" height="40"
style="border-radius:4px;image-rendering:pixelated">
<div>
<strong style="color:var(--c-text)"><?php echo esc_html( $mc_name ?: $mc_uuid ); ?></strong><br>
<small style="color:var(--c-muted);font-size:.75rem"><?php echo esc_html( $mc_uuid ); ?></small>
</div>
</div>
<p style="font-size:.82rem;color:var(--c-muted);margin:.25rem 0 .75rem">
<i class="fas fa-bell" style="color:#65a30d"></i>
Du erhältst Ingame-Benachrichtigungen bei Antworten, Erwähnungen und PNs.
</p>
<div id="wbf-mc-msg" style="font-size:.82rem;margin-bottom:.5rem"></div>
<button type="button" class="wbf-btn wbf-btn--ghost" id="wbf-mc-unlink-btn"
onclick="wbfMcUnlink()">
<i class="fas fa-unlink"></i> Verknüpfung aufheben
</button>
<?php else : ?>
<p class="wbf-connection-card__desc">
Verknüpfe deinen Minecraft-Account für Ingame-Benachrichtigungen
bei neuen Antworten, Erwähnungen und Privatnachrichten.
</p>
<p style="font-size:.82rem;color:var(--c-muted);margin-bottom:.75rem">
<i class="fas fa-terminal"></i>
Schritt 1: Token generieren &nbsp;→&nbsp;
Schritt 2: <code>/forumlink &lt;token&gt;</code> ingame eingeben
</p>
<div id="wbf-mc-token-box" style="display:none;background:var(--c-surface);border:1px solid var(--c-border);border-radius:8px;padding:.85rem 1rem;margin-bottom:.75rem">
<div style="display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.4rem">
<span style="font-size:.75rem;color:var(--c-muted);text-transform:uppercase;letter-spacing:.05em">Dein Token</span>
<span id="wbf-mc-token-timer" style="font-size:.75rem;color:#f97316;font-weight:600"></span>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<code id="wbf-mc-token-value"
style="font-size:1.4rem;letter-spacing:.25em;font-weight:700;color:var(--c-accent);flex:1"></code>
<button type="button" class="wbf-btn wbf-btn--sm" id="wbf-mc-copy-btn"
onclick="wbfMcCopyToken()" title="Befehl kopieren">
<i class="fas fa-copy"></i>
</button>
</div>
<div style="margin-top:.5rem;font-size:.8rem;color:var(--c-muted)">
Ingame eingeben: <code id="wbf-mc-cmd-value">/forumlink </code>
</div>
<div style="margin-top:.5rem">
<div style="height:4px;border-radius:2px;background:var(--c-border);overflow:hidden">
<div id="wbf-mc-token-progress"
style="height:100%;background:#65a30d;transition:width 1s linear;width:100%"></div>
</div>
</div>
</div>
<div id="wbf-mc-msg" style="font-size:.82rem;margin-bottom:.5rem"></div>
<button type="button" class="wbf-btn wbf-btn--primary" id="wbf-mc-gen-btn"
onclick="wbfMcGenerateToken()">
<i class="fas fa-key"></i> Token generieren
</button>
<?php endif; ?>
</div>
</div>
<script>
(function(){
var _pollInterval = null;
var _timerInterval = null;
var _expiry = 0;
window.wbfMcGenerateToken = function() {
var btn = document.getElementById('wbf-mc-gen-btn');
var msg = document.getElementById('wbf-mc-msg');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generiere...';
msg.textContent = '';
jQuery.post(WBF.ajax_url, { action: 'wbf_mc_generate_token', nonce: WBF.nonce }, function(r) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-rotate"></i> Neuen Token generieren';
if (r.success) {
var token = r.data.token;
_expiry = Math.floor(Date.now() / 1000) + ((r.data.expires_in || 15) * 60);
document.getElementById('wbf-mc-token-value').textContent = token;
document.getElementById('wbf-mc-cmd-value').textContent = '/forumlink ' + token;
document.getElementById('wbf-mc-token-box').style.display = 'block';
wbfMcStartTimer((r.data.expires_in || 15) * 60);
wbfMcStartPolling();
} else {
if (r.data && r.data.linked) {
msg.innerHTML = '<span style="color:#16a34a"><i class="fas fa-check-circle"></i> ' + (r.data.message || 'Bereits verknüpft.') + '</span>';
setTimeout(function(){ location.reload(); }, 1500);
} else {
msg.innerHTML = '<span style="color:#dc2626"><i class="fas fa-circle-xmark"></i> ' + (r.data && r.data.message ? r.data.message : 'Fehler') + '</span>';
}
}
});
};
function wbfMcStartTimer(seconds) {
clearInterval(_timerInterval);
var timerEl = document.getElementById('wbf-mc-token-timer');
var progressEl = document.getElementById('wbf-mc-token-progress');
var total = seconds;
_timerInterval = setInterval(function() {
var remaining = _expiry - Math.floor(Date.now() / 1000);
if (remaining <= 0) {
clearInterval(_timerInterval); clearInterval(_pollInterval);
if (timerEl) timerEl.textContent = 'Abgelaufen';
if (progressEl) progressEl.style.width = '0%';
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#f97316"><i class="fas fa-clock"></i> Token abgelaufen — bitte neuen generieren.</span>';
return;
}
var m = Math.floor(remaining / 60), s = remaining % 60;
if (timerEl) timerEl.textContent = m + ':' + (s < 10 ? '0' : '') + s;
if (progressEl) {
progressEl.style.width = Math.max(0, (remaining / total) * 100) + '%';
progressEl.style.background = remaining < 60 ? '#dc2626' : remaining < 180 ? '#f97316' : '#65a30d';
}
}, 1000);
}
function wbfMcStartPolling() {
clearInterval(_pollInterval);
_pollInterval = setInterval(function() {
jQuery.post(WBF.ajax_url, { action: 'wbf_mc_link_status', nonce: WBF.nonce }, function(r) {
if (r.success && r.data && r.data.linked) {
clearInterval(_pollInterval); clearInterval(_timerInterval);
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#16a34a"><i class="fas fa-check-circle"></i> ✓ Verknüpft mit <strong>' + (r.data.mc_name || r.data.mc_uuid) + '</strong>! Seite lädt neu...</span>';
setTimeout(function(){ location.reload(); }, 1800);
}
});
}, 5000);
}
window.wbfMcCopyToken = function() {
var cmd = document.getElementById('wbf-mc-cmd-value').textContent;
var btn = document.getElementById('wbf-mc-copy-btn');
var done = function() { btn.innerHTML = '<i class="fas fa-check"></i>'; btn.style.color = '#16a34a'; setTimeout(function(){ btn.innerHTML = '<i class="fas fa-copy"></i>'; btn.style.color = ''; }, 2000); };
if (navigator.clipboard) { navigator.clipboard.writeText(cmd).then(done); }
else { var ta = document.createElement('textarea'); ta.value = cmd; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done(); }
};
window.wbfMcUnlink = function() {
if (!confirm('Minecraft-Verknüpfung wirklich aufheben?')) return;
var btn = document.getElementById('wbf-mc-unlink-btn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Trenne...';
jQuery.post(WBF.ajax_url, { action: 'wbf_mc_unlink', nonce: WBF.nonce }, function(r) {
if (r.success) {
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#16a34a"><i class="fas fa-check"></i> ' + (r.data.message || 'Verknüpfung aufgehoben.') + '</span>';
setTimeout(function(){ location.reload(); }, 1200);
} else {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-unlink"></i> Verknüpfung aufheben';
var msg = document.getElementById('wbf-mc-msg');
if (msg) msg.innerHTML = '<span style="color:#dc2626">Fehler: ' + (r.data && r.data.message ? r.data.message : 'Unbekannt') + '</span>';
}
});
};
})();
</script>
<?php
// ── Discord-Card (eingebaut, kein extra Plugin nötig) ──────────────
$discord_meta = WBF_DB::get_user_meta( $profile->id );
$discord_current = trim( $discord_meta['discord_username'] ?? '' );
$discord_connected = $discord_current !== '';
?>
<?php
$s = wbf_get_settings();
$discord_bot_configured = ! empty( trim( $s['discord_bot_token'] ?? '' ) );
?>
<div class="wbf-connection-card wbf-connection-card--discord">
<div class="wbf-connection-card__icon">
<i class="fab fa-discord"></i>
</div>
<div class="wbf-connection-card__head">
<span class="wbf-connection-card__title">Discord</span>
<?php if ( $discord_connected ) : ?>
<span class="wbf-connection-badge wbf-connection-badge--connected">
<i class="fas fa-check-circle"></i> Verbunden
</span>
<?php else : ?>
<span class="wbf-connection-badge wbf-connection-badge--disconnected">
<i class="fas fa-circle-xmark"></i> Nicht verbunden
</span>
<?php endif; ?>
</div>
<div class="wbf-connection-card__content">
<?php if ( $discord_connected ) : ?>
<!-- ── Bereits verbunden ── -->
<div class="wbf-discord-connected-info">
<span class="wbf-discord-linked-name">
<i class="fab fa-discord" style="color:#5865f2"></i>
<?php echo esc_html( $discord_current ); ?>
</span>
</div>
<div class="wbf-connect-row" style="margin-top:.75rem">
<button type="button" class="wbf-btn wbf-btn--primary" id="wbf-discord-relink">
<i class="fas fa-rotate"></i> Neu verknüpfen
</button>
<button type="button" class="wbf-btn wbf-btn--ghost" id="wbf-discord-disconnect">
<i class="fas fa-unlink"></i> Trennen
</button>
</div>
<div id="wbf-discord-msg" style="margin-top:.5rem;font-size:.82rem"></div>
<!-- Formular (standardmäßig ausgeblendet, bei "Neu verknüpfen" sichtbar) -->
<div id="wbf-discord-form" style="display:none;margin-top:1rem">
<?php self::render_discord_form( $discord_bot_configured ); ?>
</div>
<?php else : ?>
<!-- ── Noch nicht verbunden ── -->
<p class="wbf-connection-card__desc">
Verknüpfe deinen Discord-Account mit deinem Profil.
<?php if ( $discord_bot_configured ) : ?>
Ein Bestätigungs-Code wird dir per Discord-DM zugeschickt.
<?php else : ?>
<em style="color:var(--c-muted)">(Bot noch nicht konfiguriert wende dich an einen Admin.)</em>
<?php endif; ?>
</p>
<div id="wbf-discord-msg" style="margin-top:.3rem;font-size:.82rem"></div>
<div id="wbf-discord-form">
<?php self::render_discord_form( $discord_bot_configured ); ?>
</div>
<?php endif; ?>
</div>
</div>
<?php
// Hook für weitere Verbindungen (z.B. Steam, Twitch, …)
// Nutzung: add_filter('wbf_profile_connections', function($html, $profile) {
// return $html . '<div class="wbf-connection-card">…</div>';
// }, 10, 2);
echo apply_filters('wbf_profile_connections', '', $profile);
?>
</div>
</div>
<?php endif; /* end Tab MC */ ?>
</div><!-- /.wbf-profile-main -->
</div><!-- /.wbf-profile-layout -->
</div>
</div>
<?php self::render_auth_modal(); ?>
<?php return ob_get_clean();
}
// ── TAG PAGE ─────────────────────────────────────────────────────────────
// ── Discord Verifikations-Formular (3-Schritt) ────────────────────────────
private static function render_discord_form( $bot_configured ) { ?>
<?php if ( ! $bot_configured ) : ?>
<p style="color:var(--c-muted);font-size:.83rem;margin:0">
<i class="fas fa-triangle-exclamation"></i>
Discord-Bot noch nicht eingerichtet. Bitte Admin kontaktieren.
</p>
<?php else : ?>
<!-- Schritt 1: Benutzername eingeben -->
<div id="wbf-dc-step1">
<label>DISCORD-BENUTZERNAME</label>
<div class="wbf-connect-row">
<input type="text"
id="wbf-discord-input"
placeholder="z. B. MvViper"
maxlength="40"
autocomplete="off">
<button type="button" class="wbf-btn wbf-btn--primary" id="wbf-discord-send-code">
<i class="fab fa-discord"></i> Code senden
</button>
</div>
<p style="font-size:.78rem;color:var(--c-muted);margin:.45rem 0 0">
<i class="fas fa-info-circle"></i>
Du musst Mitglied unseres Discord-Servers sein und DMs erlauben.
</p>
</div>
<!-- Schritt 2: Code eingeben (zunächst ausgeblendet) -->
<div id="wbf-dc-step2" style="display:none;margin-top:.9rem">
<label>BESTÄTIGUNGS-CODE (aus Discord-DM)</label>
<div class="wbf-connect-row">
<input type="text"
id="wbf-discord-code-input"
placeholder="A1B2C3"
maxlength="6"
autocomplete="off"
style="font-family:monospace;letter-spacing:.15em;text-transform:uppercase;max-width:140px">
<button type="button" class="wbf-btn wbf-btn--primary" id="wbf-discord-verify">
<i class="fas fa-check"></i> Bestätigen
</button>
<button type="button" class="wbf-btn wbf-btn--ghost" id="wbf-discord-code-back">
<i class="fas fa-arrow-left"></i> Zurück
</button>
</div>
<p style="font-size:.78rem;color:var(--c-muted);margin:.45rem 0 0">
<i class="fas fa-clock"></i> Code ist 10 Minuten gültig.
</p>
</div>
<?php endif; ?>
<?php }
private static function view_tag() {
$slug = sanitize_title( $_GET['forum_tag'] ?? '' );
$tag = WBF_DB::get_tag($slug);

View File

@@ -0,0 +1,179 @@
<?php
if ( ! defined( 'ABSPATH' ) ) exit;
/**
* WBF_TOTP — RFC 6238 Time-based One-Time Password (TOTP)
*
* Keine externe Bibliothek nötig — reines PHP 7.0+.
* Kompatibel mit: Google Authenticator, Aegis, Authy, Bitwarden, 2FAS, etc.
*
* Secrets werden in forum_user_meta gespeichert (meta_key = 'totp_secret').
* Kein Schema-Change an der Haupt-Usertabelle nötig.
*/
class WBF_TOTP {
const DIGITS = 6;
const PERIOD = 30; // Sekunden pro Schritt
const WINDOW = 1; // ±1 Step Toleranz (= ±30 s Uhrabweichung OK)
const SECRET_LEN = 20; // Bytes → 32 Base32-Zeichen
// Meta-Keys
const META_SECRET = 'totp_secret';
const META_PENDING = 'totp_secret_pending';
// Session-Key für ausstehenden Login
const SESSION_PENDING = 'wbf_2fa_pending';
// ── Secret ──────────────────────────────────────────────────────────────
/**
* Erzeugt einen neuen, kryptografisch sicheren Base32-Secret.
* @return string z.B. "JBSWY3DPEBLW64TMMQ======"
*/
public static function generate_secret() {
return self::base32_encode( random_bytes( self::SECRET_LEN ) );
}
// ── Verifikation ──────────────────────────────────────────────────────────
/**
* Prüft ob $code für $secret zum aktuellen Zeitfenster passt.
*
* @param string $secret Base32-Secret des Users
* @param string $code 6-stelliger Code aus der Authenticator-App
* @param int $window Anzahl Steps Toleranz (default = 1 = ±30 s)
* @return bool
*/
public static function verify( $secret, $code, $window = self::WINDOW ) {
// Leerzeichen tolerieren (z.B. "123 456")
$code = preg_replace( '/\s+/', '', (string) $code );
if ( strlen($code) !== self::DIGITS ) return false;
if ( ! ctype_digit($code) ) return false;
$key = self::base32_decode( $secret );
if ( empty($key) ) return false;
$ts = (int) floor( time() / self::PERIOD );
for ( $i = -$window; $i <= $window; $i++ ) {
$expected = self::hotp( $key, $ts + $i );
// Timing-safe Vergleich
if ( hash_equals( $expected, $code ) ) return true;
}
return false;
}
// ── HOTP-Kern (RFC 4226) ─────────────────────────────────────────────────
private static function hotp( $key, $counter ) {
// 64-bit Big-Endian Counter
$msg = pack( 'N', 0 ) . pack( 'N', $counter );
$hash = hash_hmac( 'sha1', $msg, $key, true );
$offset = ord( $hash[19] ) & 0x0f;
$code = (
( ord($hash[$offset ]) & 0x7f ) << 24 |
( ord($hash[$offset + 1]) & 0xff ) << 16 |
( ord($hash[$offset + 2]) & 0xff ) << 8 |
( ord($hash[$offset + 3]) & 0xff )
) % ( 10 ** self::DIGITS );
return str_pad( (string) $code, self::DIGITS, '0', STR_PAD_LEFT );
}
// ── otpauth:// URI ────────────────────────────────────────────────────────
/**
* Gibt die otpauth:// URI zurück — wird vom QR-Code-Generator verwendet.
*
* @param string $username Forum-Benutzername
* @param string $secret Base32-Secret
* @param string|null $issuer Anzeigename in der App (default: Blogname)
* @return string
*/
public static function get_otpauth_uri( $username, $secret, $issuer = null ) {
if ( ! $issuer ) {
$issuer = html_entity_decode( get_bloginfo('name'), ENT_QUOTES ) ?: 'WP Business Forum';
}
$label = rawurlencode( $issuer . ':' . $username );
return 'otpauth://totp/' . $label . '?'
. 'secret=' . rawurlencode( $secret )
. '&issuer=' . rawurlencode( $issuer )
. '&algorithm=SHA1'
. '&digits=' . self::DIGITS
. '&period=' . self::PERIOD;
}
// ── User-Helfer ───────────────────────────────────────────────────────────
/** Ist 2FA für diesen User aktiv? */
public static function is_enabled_for( $user_id ) {
$s = WBF_DB::get_user_meta_single( (int) $user_id, self::META_SECRET );
return ! empty( $s );
}
/**
* 2FA für einen User deaktivieren (löscht Secret + ggf. pending Secret).
* Kann von Admin und User selbst (nach Verifikation) aufgerufen werden.
*/
public static function disable_for( $user_id ) {
global $wpdb;
$uid = (int) $user_id;
$wpdb->delete(
"{$wpdb->prefix}forum_user_meta",
[ 'user_id' => $uid, 'meta_key' => self::META_SECRET ],
[ '%d', '%s' ]
);
$wpdb->delete(
"{$wpdb->prefix}forum_user_meta",
[ 'user_id' => $uid, 'meta_key' => self::META_PENDING ],
[ '%d', '%s' ]
);
}
// ── Base32 ────────────────────────────────────────────────────────────────
private static $b32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
public static function base32_encode( $input ) {
$output = '';
$buf = 0;
$buf_bits = 0;
for ( $i = 0, $len = strlen($input); $i < $len; $i++ ) {
$buf = ( $buf << 8 ) | ord( $input[$i] );
$buf_bits += 8;
while ( $buf_bits >= 5 ) {
$buf_bits -= 5;
$output .= self::$b32[ ( $buf >> $buf_bits ) & 0x1f ];
}
}
if ( $buf_bits > 0 ) {
$output .= self::$b32[ ( $buf << ( 5 - $buf_bits ) ) & 0x1f ];
}
// Padding to multiple of 8
while ( strlen($output) % 8 !== 0 ) $output .= '=';
return $output;
}
public static function base32_decode( $input ) {
// Leerzeichen & Padding entfernen, Uppercase
$input = strtoupper( preg_replace( '/\s+/', '', $input ) );
$input = rtrim( $input, '=' );
$map = array_flip( str_split( self::$b32 ) );
$output = '';
$buf = 0;
$bits = 0;
for ( $i = 0, $len = strlen($input); $i < $len; $i++ ) {
if ( ! isset( $map[ $input[$i] ] ) ) continue; // ungültiges Zeichen ignorieren
$buf = ( $buf << 5 ) | $map[ $input[$i] ];
$bits += 5;
if ( $bits >= 8 ) {
$bits -= 8;
$output .= chr( ( $buf >> $bits ) & 0xff );
}
}
return $output;
}
}

View File

@@ -0,0 +1,32 @@
<?php
// Ingame-Benachrichtigung via StatusAPI
if ( ! defined( 'ABSPATH' ) ) exit;
function wbf_notify_ingame($player, $message) {
// Einstellungen laden
$settings = function_exists('wbf_get_settings') ? wbf_get_settings() : [];
$enabled = !empty($settings['mc_bridge_enabled']);
$api_url = trim($settings['mc_bridge_api_url'] ?? '');
$api_secret = trim($settings['mc_bridge_api_secret'] ?? '');
if (!$enabled || !$api_url || !$api_secret) {
return;
}
$url = rtrim($api_url, '/') . '/notify-pn';
$data = [
'player' => $player,
'message' => $message
];
$args = [
'body' => json_encode($data),
'headers' => [
'Content-Type' => 'application/json',
'X-API-Key' => $api_secret,
],
'timeout' => 2,
'data_format' => 'body',
];
wp_remote_post($url, $args);
}

View File

@@ -1,20 +1,27 @@
<?php
/**
* Plugin Name: WP Business Forum
* Plugin URI: https://git.viper.ipv64.net/M_Viper/WP-Business-Forum
* Description: Professionelles Forum mit eigenem Login, Rollen, Signaturen, Hierarchie und Moderations-Tools.
* Version: 1.0.3
* Author: M_Viper
* Author URI: https://m-viper.de
* Text Domain: wp-business-forum
* Requires PHP: 7.0
/*
Plugin Name: WP Business Forum
Plugin URI:https://git.viper.ipv64.net/M_Viper/WP-Business-Forum
Description: Professionelles Forum mit eigenem Login, Rollen, Signaturen, Hierarchie und Moderations-Tools.
Version: 1.0.5
Author: M_Viper
Author URI: https://m-viper.de
Requires at least: 6.8
Tested up to: 6.8
PHP Version: 7.4
License: GPL2
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-business-forum
Tags: forum, community, roles, moderation, shortcode, bbcode, admin, frontend, security, export-import
Support: [Discord Support](https://discord.com/invite/FdRs4BRd8D)
Support: [Telegram Support](https://t.me/M_Viper04)
*/
if ( ! defined( 'ABSPATH' ) ) exit;
define( 'WBF_PATH', plugin_dir_path( __FILE__ ) );
define( 'WBF_URL', plugin_dir_url( __FILE__ ) );
define( 'WBF_VERSION', '1.0.2' );
define( 'WBF_VERSION', '1.0.5' );
require_once WBF_PATH . 'includes/class-forum-db.php';
require_once WBF_PATH . 'includes/class-forum-roles.php';
@@ -24,6 +31,9 @@ require_once WBF_PATH . 'includes/class-forum-auth.php';
require_once WBF_PATH . 'includes/class-forum-shortcodes.php';
require_once WBF_PATH . 'includes/class-forum-ajax.php';
require_once WBF_PATH . 'includes/class-forum-export.php';
require_once WBF_PATH . 'includes/class-forum-mc-bridge.php';
require_once WBF_PATH . 'includes/class-forum-totp.php';
require_once WBF_PATH . 'includes/forum-statusapi.php';
require_once WBF_PATH . 'admin/forum-admin.php';
require_once WBF_PATH . 'admin/forum-settings.php';
require_once WBF_PATH . 'admin/forum-setup.php';
@@ -103,14 +113,13 @@ function wbf_get_forum_url() {
$url = get_permalink( $page_id );
if ( $url ) return $url;
}
// 2. Fallback: Seite mit [business_forum] Shortcode suchen
$pages = get_posts([
'post_type' => 'page',
'post_status' => 'publish',
'posts_per_page' => 1,
's' => 'business_forum',
]);
if ( $pages ) return get_permalink( $pages[0]->ID );
// 2. Fallback: Seite mit [business_forum] Shortcode suchen (direkt im post_content)
global $wpdb;
$page_id = $wpdb->get_var( $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status = 'publish' AND post_content LIKE %s LIMIT 1",
'%[business_forum]%'
) );
if ( $page_id ) return get_permalink( $page_id );
// 3. Letzter Fallback: aktuelle Seite
return home_url('/');
}
@@ -119,6 +128,10 @@ function wbf_get_forum_url() {
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_style( 'wbf-style', WBF_URL . 'assets/css/forum-style.css', [], WBF_VERSION );
wp_enqueue_script( 'wbf-script', WBF_URL . 'assets/js/forum-script.js', ['jquery'], WBF_VERSION, true );
// 2FA: QR-Code-Bibliothek (nur laden wenn User eingeloggt oder auf Loginseite)
wp_enqueue_script( 'qrcodejs', 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js', [], '1.0.0', true );
wp_add_inline_script( 'wbf-script', wbf_get_2fa_inline_js(), 'after' );
$wbf_user = WBF_Auth::get_current_user();
if ( $wbf_user ) {
WBF_DB::touch_last_active( $wbf_user->id );
@@ -286,3 +299,301 @@ add_action( 'admin_init', function() {
wp_safe_redirect( remove_query_arg(['wbf_refresh_update','_wpnonce']) );
exit;
} );
// ══════════════════════════════════════════════════════════════════════════════
// ── 2FA Inline-JavaScript ─────────────────────────────────────────────────────
// Liefert das JS für: Login-2FA-Step, Profil-Setup-Wizard, Deaktivierung
// ══════════════════════════════════════════════════════════════════════════════
function wbf_get_2fa_inline_js() {
return <<<'JS'
(function ($) {
'use strict';
/* ══════════════════════════════════════════════════════════════
2FA — Login-Flow
Wenn der Server 2fa_required:true zurückgibt, zeigt das
Login-Formular eine Code-Eingabe anstatt die Fehlermeldung.
══════════════════════════════════════════════════════════════ */
// Original-Login-Handler überschreiben um 2FA abzufangen
$(document).off('click', '.wbf-login-submit-btn');
$(document).on('click', '.wbf-login-submit-btn', function () {
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
var $box = $(this).closest('.wbf-auth-box');
// 2FA-Panel verstecken falls sichtbar
$box.find('.wbf-2fa-login-step').remove();
$.post(WBF.ajax_url, {
action: 'wbf_login',
nonce: WBF.nonce,
username: $box.find('.wbf-field-username').val(),
password: $box.find('.wbf-field-password').val(),
remember_me: $box.find('.wbf-field-remember').is(':checked') ? '1' : ''
}, function (res) {
if (res && res.success) {
location.reload();
} else if (res && res.data && res.data['2fa_required']) {
// 2FA erforderlich — Code-Eingabe einblenden
$btn.prop('disabled', false).html('<i class="fas fa-sign-in-alt"></i> Einloggen');
wbfShow2faLoginStep($box);
} else {
var msg = (res && res.data && res.data.message) ? res.data.message : 'Fehler';
$box.find('.wbf-login-msg').text(msg).css('color', '#f05252').show();
setTimeout(function () { $box.find('.wbf-login-msg').fadeOut(); }, 4000);
$btn.prop('disabled', false).html('<i class="fas fa-sign-in-alt"></i> Einloggen');
}
}, 'json').fail(function (xhr) {
$box.find('.wbf-login-msg').text('Verbindungsfehler (' + xhr.status + ')').css('color', '#f05252').show();
$btn.prop('disabled', false).html('<i class="fas fa-sign-in-alt"></i> Einloggen');
});
});
function wbfShow2faLoginStep($box) {
// Altes Modal entfernen falls vorhanden
$('#wbf2faLoginModal').remove();
var modal =
'<div id="wbf2faLoginModal" class="wbf-2fa-modal-overlay">' +
'<div class="wbf-2fa-modal-box">' +
'<div class="wbf-2fa-modal-header">' +
'<span class="wbf-2fa-modal-icon">🛡️</span>' +
'<div>' +
'<strong class="wbf-2fa-modal-title">Zwei-Faktor-Authentifizierung</strong>' +
'<p class="wbf-2fa-modal-sub">Gib den Code aus deiner Authenticator-App ein.</p>' +
'</div>' +
'</div>' +
'<input type="text" class="wbf-2fa-code-input" placeholder="1 2 3 4 5 6"' +
' maxlength="7" inputmode="numeric" autocomplete="one-time-code">' +
'<div class="wbf-2fa-modal-actions">' +
'<button class="wbf-btn wbf-btn--primary wbf-2fa-submit-btn">' +
'<i class="fas fa-check"></i> Bestätigen' +
'</button>' +
'<button class="wbf-btn wbf-2fa-cancel-btn">' +
'<i class="fas fa-xmark"></i> Abbrechen' +
'</button>' +
'</div>' +
'<span class="wbf-2fa-msg"></span>' +
'</div>' +
'</div>';
$('body').append(modal);
// Kurze Verzögerung für CSS-Transition
setTimeout(function () {
$('#wbf2faLoginModal').addClass('wbf-2fa-modal--visible');
$('#wbf2faLoginModal .wbf-2fa-code-input').focus();
}, 20);
}
// 2FA-Code absenden
$(document).on('click', '.wbf-2fa-submit-btn', function () {
var $step = $(this).closest('.wbf-2fa-modal-box');
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
var code = $step.find('.wbf-2fa-code-input').val().replace(/\s+/g, '');
if (code.length !== 6) {
$step.find('.wbf-2fa-msg').text('Bitte 6-stelligen Code eingeben.').css('color', '#f05252').show();
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Code bestätigen');
return;
}
$.post(WBF.ajax_url, {
action: 'wbf_2fa_verify_login',
code: code
}, function (res) {
if (res && res.success) {
location.reload();
} else {
var msg = (res && res.data && res.data.message) ? res.data.message : 'Ungültiger Code.';
$step.find('.wbf-2fa-msg').text(msg).css('color', '#f05252').show();
$step.find('.wbf-2fa-code-input').val('').focus();
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Code bestätigen');
}
}, 'json').fail(function (xhr) {
$step.find('.wbf-2fa-msg').text('Verbindungsfehler.').css('color', '#f05252').show();
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Code bestätigen');
});
});
// Enter-Taste im Code-Feld
$(document).on('keydown', '.wbf-2fa-code-input', function (e) {
if (e.key === 'Enter') $(this).closest('.wbf-2fa-login-step').find('.wbf-2fa-submit-btn').click();
});
// Abbrechen: 2FA-Modal schließen
$(document).on('click', '.wbf-2fa-cancel-btn', function () {
var $modal = $('#wbf2faLoginModal');
$modal.removeClass('wbf-2fa-modal--visible');
setTimeout(function () { $modal.remove(); }, 250);
});
// Klick außerhalb des Modals schließt es
$(document).on('click', '#wbf2faLoginModal', function (e) {
if ($(e.target).is('#wbf2faLoginModal')) {
var $modal = $(this);
$modal.removeClass('wbf-2fa-modal--visible');
setTimeout(function () { $modal.remove(); }, 250);
}
});
/* ══════════════════════════════════════════════════════════════
2FA — Profil-Setup-Wizard
══════════════════════════════════════════════════════════════ */
// Schritt 1 starten: Secret + QR generieren
$(document).on('click', '#wbf2faStartBtn', function () {
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Lädt…');
$.post(WBF.ajax_url, {
action: 'wbf_2fa_setup_begin',
nonce: WBF.nonce
}, function (res) {
if (!res || !res.success) {
$btn.prop('disabled', false).html('<i class="fas fa-shield-halved"></i> 2FA einrichten');
alert((res && res.data && res.data.message) ? res.data.message : 'Fehler');
return;
}
var secret = res.data.secret;
var uri = res.data.uri;
// QR-Code rendern (qrcodejs)
$('#wbf2faQr').empty();
if (typeof QRCode !== 'undefined') {
// QR-Code in isolierten Wrapper einbetten (kein Flex-Kontext)
var qrEl = document.getElementById('wbf2faQr');
qrEl.innerHTML = '';
new QRCode(qrEl, {
text: uri,
width: 200,
height: 200,
correctLevel: QRCode.CorrectLevel.M
});
// Kein JS-Eingriff nötig — CSS übernimmt Größe + img-Verstecken
} else {
// Fallback: Link anzeigen
$('#wbf2faQr').html(
'<a href="' + uri + '" style="font-size:.75rem;word-break:break-all">otpauth Link</a>'
);
}
// Secret für manuelle Eingabe formatiert anzeigen (Leerzeichen alle 4 Zeichen)
var fmt = secret.replace(/=/g, '').replace(/(.{4})/g, '$1 ').trim();
$('#wbf2faSecret').text(fmt);
// Panels tauschen
$('#wbf2faInactive').hide();
$('#wbf2faStep1').fadeIn(200);
}, 'json').fail(function () {
$btn.prop('disabled', false).html('<i class="fas fa-shield-halved"></i> 2FA einrichten');
alert('Verbindungsfehler. Bitte Seite neu laden.');
});
});
// Weiter zu Schritt 2
$(document).on('click', '#wbf2faToStep2', function () {
$('#wbf2faStep1').hide();
$('#wbf2faStep2').fadeIn(200);
$('#wbf2faVerifyCode').focus();
});
// Zurück zu Schritt 1
$(document).on('click', '#wbf2faBackBtn', function () {
$('#wbf2faStep2').hide();
$('#wbf2faStep1').fadeIn(200);
});
// Schritt 2: Code bestätigen und 2FA aktivieren
$(document).on('click', '#wbf2faVerifyBtn', function () {
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
var $msg = $('#wbf2faVerifyMsg');
var code = $('#wbf2faVerifyCode').val().replace(/\s+/g, '');
if (code.length !== 6) {
$msg.text('Bitte 6-stelligen Code eingeben.').css('color', '#f05252').show();
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Bestätigen & aktivieren');
return;
}
$.post(WBF.ajax_url, {
action: 'wbf_2fa_setup_verify',
nonce: WBF.nonce,
code: code
}, function (res) {
if (res && res.success) {
$('#wbf2faStep2').hide();
$('#wbf2faStep3').fadeIn(300);
// Badge im Header aktualisieren
$('#wbf2faCard .wbf-2fa-badge')
.removeClass('wbf-2fa-badge--off')
.addClass('wbf-2fa-badge--on')
.html('<i class="fas fa-check-circle"></i> Aktiv');
// Nach 2 Sek. Seite neu laden damit der Header-Status stimmt
setTimeout(function () { location.reload(); }, 2500);
} else {
var msg = (res && res.data && res.data.message) ? res.data.message : 'Ungültiger Code.';
$msg.text(msg).css('color', '#f05252').show();
$('#wbf2faVerifyCode').val('').focus();
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Bestätigen & aktivieren');
}
}, 'json').fail(function () {
$msg.text('Verbindungsfehler.').css('color', '#f05252').show();
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Bestätigen & aktivieren');
});
});
// Enter-Taste im Verifikationsfeld
$(document).on('keydown', '#wbf2faVerifyCode', function (e) {
if (e.key === 'Enter') $('#wbf2faVerifyBtn').click();
});
/* ══════════════════════════════════════════════════════════════
2FA — Deaktivierung (Profil)
══════════════════════════════════════════════════════════════ */
$(document).on('click', '#wbf2faDisableBtn', function () {
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
var $msg = $('#wbf2faDisableMsg');
var pw = $('#wbf2faDisablePw').val();
var code = $('#wbf2faDisableCode').val().replace(/\s+/g, '');
if (!pw) {
$msg.text('Bitte Passwort eingeben.').css('color', '#f05252').show();
$btn.prop('disabled', false)
.html('<i class="fas fa-shield-xmark"></i> 2FA deaktivieren');
return;
}
if (code.length !== 6) {
$msg.text('Bitte 6-stelligen Code eingeben.').css('color', '#f05252').show();
$btn.prop('disabled', false)
.html('<i class="fas fa-shield-xmark"></i> 2FA deaktivieren');
return;
}
$.post(WBF.ajax_url, {
action: 'wbf_2fa_disable',
nonce: WBF.nonce,
password: pw,
code: code
}, function (res) {
if (res && res.success) {
$msg.text('✔ ' + (res.data.message || '2FA deaktiviert.')).css('color', '#56cf7e').show();
setTimeout(function () { location.reload(); }, 1500);
} else {
var msg = (res && res.data && res.data.message) ? res.data.message : 'Fehler.';
$msg.text(msg).css('color', '#f05252').show();
$('#wbf2faDisableCode').val('').focus();
$btn.prop('disabled', false)
.html('<i class="fas fa-shield-xmark"></i> 2FA deaktivieren');
}
}, 'json').fail(function () {
$msg.text('Verbindungsfehler.').css('color', '#f05252').show();
$btn.prop('disabled', false)
.html('<i class="fas fa-shield-xmark"></i> 2FA deaktivieren');
});
});
}(jQuery));
JS;
}