12 Commits
1.0.4 ... main

18 changed files with 4222 additions and 503 deletions

686
README.md
View File

@@ -1,396 +1,290 @@
# WP Business Forum Anwender-Dokumentation
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.
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.
---
## Inhalt
1. Über das Plugin
2. Funktionsübersicht
3. Voraussetzungen
4. Installation
5. Ersteinrichtung (Setup-Wizard)
6. Forum-Seite einbinden
7. Bedienung im Frontend (Mitglieder)
8. Moderation und Verwaltung
9. Einstellungen im Detail
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
- Likes und Emoji-Reaktionen
- Tags und Thread-Präfixe
- Umfragen erstellen und abstimmen
- Lesezeichen setzen
- Nutzer erwähnen mit @mention
- 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)
- 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.)
---
## 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)
- 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).
---
## 4) Installation
1. Plugin-Ordner `wp-business-forum` in `wp-content/plugins/` kopieren.
2. Im WordPress-Backend unter **Plugins** aktivieren.
3. Nach der Aktivierung startet einmalig der Setup-Wizard.
---
## 5) Ersteinrichtung (Setup-Wizard)
Nach der Aktivierung führt der Wizard durch drei Schritte:
1. Superadmin-Konto erstellen oder bestehendes Forum-Konto verknüpfen
2. Optional automatisch eine Forum-Seite erzeugen
3. Abschluss und Weiterleitung ins Dashboard
**Wichtig:**
- Der Superadmin ist fest mit dem WordPress-Administrator verknüpft und kann nicht über den Import überschrieben werden.
- 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:
```
[business_forum]
```
**Empfehlung:**
- 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.
- Optional müssen Nutzer die Forum-Regeln akzeptieren.
- Spam-Schutz bei der Registrierung:
- Honeypot-Feld
- Mindestzeit bis zum Formular-Absenden
- Login unterstützt „Angemeldet bleiben" (Remember-Me Cookie, 30 Tage).
### 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
### 7.3 Thread erstellen
- Mindestlänge Titel: 5 Zeichen
- Mindestlänge Inhalt: 10 Zeichen
- 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.)
- 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
### 7.5 Umfragen
- Umfrage direkt beim Thread-Erstellen oder nachträglich anfügen
- 2 bis 10 Antwortoptionen
- Optional Mehrfachauswahl
- Optional Enddatum
- Nach der Abstimmung werden Ergebnisse direkt angezeigt
### 7.6 Reaktionen, Likes, Lesezeichen
- Likes auf Threads und Beiträge
- Emoji-Reaktionen (adminseitig konfigurierbar)
- Lesezeichen für Threads, im Profil jederzeit einsehbar
### 7.7 Private Nachrichten (DM)
- 1:1 Nachrichten zwischen Mitgliedern
- Inbox-Ansicht und Konversationsansicht
- Ungelesene Nachrichten werden im Header 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
### 7.9 Profil
Mitglieder können:
- Anzeigenamen, Bio und Signatur pflegen
- Avatar hochladen (max. 2 MB, JPG/PNG/GIF/WebP)
- Passwort ändern
- Profil-Sichtbarkeit umschalten
- Benutzerdefinierte Profilfelder ausfüllen (falls vom Admin aktiviert)
- Andere Nutzer zur Ignore-Liste hinzufügen
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.
---
## 8) Moderation und Verwaltung
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.
---
## 9) Einstellungen im Detail
Unter **Business Forum Einstellungen**:
### 9.1 Texte und UI
- Hero-Titel und Untertitel
- Topbar-Brand
- Labels für Statistiken
- Abschnittstitel und 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
### 9.3 Registrierung
- Modus: **offen** · **nur Einladung** · **deaktiviert**
- Freitext-Hinweis bei Einladungs-Modus
- Forum-Regeln bei Registrierung verpflichtend akzeptieren
### 9.4 Wartungsmodus
- Forum für normale Nutzer sperren
- Moderation und Admins behalten vollen Zugriff
- Eigener Wartungs-Titel und Hinweistext konfigurierbar
### 9.5 Forum-Regeln / Nutzungsbedingungen
- Regelseite aktivieren / deaktivieren
- Akzeptierung bei Registrierung optional verpflichtend
- Titel und Inhalt frei editierbar (unterstützt einfaches Markdown)
---
## 10) Export, Import und Deinstallation
### 10.1 Export
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`
- Transients
- Geplante Cron-Jobs
- Automatisch erstellte Forum-Seite
- Upload-Unterverzeichnis `wbf-avatars`
> **Das ist eine echte, unwiderrufliche Datenlöschung. Immer vorher einen vollständigen Export erstellen.**
---
## 11) FAQ / Troubleshooting
**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 den Einstellungen den Registrierungsmodus prüfen. Bei deaktiviertem Modus ist keine Selbstregistrierung möglich.
**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 im Uploads-Verzeichnis 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.
**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.
---
## 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
Viel Erfolg mit deinem Forum!
# 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.
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
4. Installation
5. Ersteinrichtung (Setup-Wizard)
6. Forum-Seite einbinden
7. Bedienung im Frontend (Mitglieder)
8. Moderation und Verwaltung
9. Einstellungen im Detail
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
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
- Likes und Emoji-Reaktionen
- Tags und Thread-Präfixe
- Umfragen erstellen und abstimmen
- Lesezeichen setzen
- Nutzer erwähnen mit @mention
- Private Nachrichten (DM)
- Profil mit Avatar, Bio, Signatur und eigenen Profilfeldern
- Mitgliederliste und Suchfunktion
### Für Moderation / Admin
- Threads pinnen, schließen, archivieren, verschieben, löschen
- Beiträge löschen
- Meldungen (Reports) bearbeiten
- Kategorien und Rollen verwalten
- Einladungssystem für Registrierung
- Wartungsmodus
- Wortfilter
- Statistiken
- Papierkorb / Wiederherstellung
- Export / Import
## 3) Voraussetzungen
- 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)
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.
3. Nach der Aktivierung startet einmalig der Setup-Wizard.
## 5) Ersteinrichtung (Setup-Wizard)
Nach Aktivierung führt der Wizard durch 3 Schritte:
1. Superadmin-Konto erstellen oder bestehendes Forum-Konto hochstufen
2. Optional automatisch eine Forum-Seite erzeugen
3. Abschluss
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]
```
Empfohlen:
- Eine eigene Seite (z. B. "Forum") anlegen
- Nur diesen Shortcode als Seiteninhalt verwenden
## 7) Bedienung im Frontend (Mitglieder)
### 7.1 Registrierung und Login
- Registrierung kann offen, nur per Einladung oder deaktiviert sein.
- Optional müssen Nutzer die Forum-Regeln akzeptieren.
- Spam-Schutz bei Registrierung:
- Honeypot-Feld
- Mindestzeit bis Formular-Absenden
- Login unterstützt "Angemeldet bleiben" (Remember-Me Cookie).
### 7.2 Kategorien und Threads
- Kategorien können verschachtelt sein (Hauptkategorie + Unterkategorien).
- 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 (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
- Flood-Control: konfigurierbare Wartezeit zwischen Posts
- 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 im Thread
- 2 bis 10 Antwortoptionen
- Optional Mehrfachauswahl
- Optional Enddatum
- Nach Abstimmung werden Ergebnisse direkt angezeigt
### 7.6 Reaktionen, Likes, Lesezeichen
- Likes auf Thread/Beitrag
- Emoji-Reaktionen (adminseitig konfigurierbar)
- Lesezeichen für Threads (im Profil einsehbar)
### 7.7 Private Nachrichten (DM)
- 1:1 Nachrichten zwischen Mitgliedern
- Inbox-Ansicht und Konversation
- Ungelesene Nachrichten werden gezählt
- Optional E-Mail-Hinweis bei neuer Nachricht
### 7.8 Benachrichtigungen
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
- Passwort ändern
- 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 gültigen Token.
## 8) Moderation und Verwaltung
Im WordPress-Backend gibt es den Menüpunkt "Business Forum" mit Unterseiten:
- Ü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:
### 9.1 Texte und UI
- Hero-Titel/Untertitel
- Topbar-Brand
- Label für Statistik
- Abschnittstitel
- Buttontexte
- Sidebar-Titel
### 9.2 Sicherheit
- 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 für Einladungsmode
### 9.4 Wartungsmodus
- Forum für normale Nutzer sperren
- 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
## 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
Empfehlung:
- Vor großen Änderungen immer einen Voll-Export speichern.
### 10.2 Deinstallation (wichtig)
Beim Löschen des Plugins werden komplett entfernt:
- alle Forum-Datenbanktabellen
- relevante Plugin-Optionen
- Transients
- geplanter Cron-Job
- automatisch erstellte Forum-Seite
- zugehörige Upload-Unterverzeichnisse
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
### Registrierung nicht sichtbar
- In 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
### 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
### Benutzer werden automatisch ausgeloggt
- Auto-Logout in den Forum-Einstellungen prüfen
### Forum ist plötzlich "offline"
- Wartungsmodus in den Einstellungen deaktivieren
### 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. 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 );
$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'">
+ Neuen Nutzer anlegen
</button>
<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);
@@ -3837,4 +4094,232 @@ function wbf_admin_wordfilter() {
</form>
</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,18 +125,22 @@ 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)) {
$page_id = wp_insert_post([
'post_title' => $page_title,
'post_content' => '[business_forum]',
'post_status' => 'publish',
'post_type' => 'page',
]);
if ($page_id) {
update_option('wbf_forum_page_id', $page_id);
$success = get_permalink($page_id);
}
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]',
'post_status' => 'publish',
'post_type' => 'page',
]);
if ($page_id) {
update_option('wbf_forum_page_id', $page_id);
$success = get_permalink($page_id);
}
} else {
$success = get_permalink($existing[0]->ID);
}

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;
@@ -3717,4 +4059,230 @@ select.wbf-cf-input option { background: var(--c-surface2); color: var(--c-text)
.wbf-prof__header-card-inner { padding: 1.1rem; }
.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();
});
}(jQuery));
// ── 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

@@ -3,7 +3,7 @@ if ( ! defined( 'ABSPATH' ) ) exit;
class WBF_Auth {
const SESSION_KEY = 'wbf_forum_user';
const SESSION_KEY = 'wbf_forum_user';
public static function init() {
// PHP 8.3: session_start() nach gesendeten Headers erzeugt E_WARNING,
@@ -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,20 +192,37 @@ 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);
$wp_user = wp_get_current_user();
$forum_user = WBF_DB::get_user_by( 'email', $wp_user->user_email );
if ( $forum_user && $forum_user->role !== self::SUPERADMIN ) {
WBF_DB::update_user($forum_user->id, ['role' => self::SUPERADMIN]);
WBF_DB::update_user( $forum_user->id, [ 'role' => self::SUPERADMIN ] );
}
}
}

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,12 +928,19 @@ 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]) ) {
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_own && $active_tab !== 2 ) $active_tab = 2;
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(); ?>
<div class="wbf-wrap">
@@ -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 );
@@ -285,4 +298,302 @@ add_action( 'admin_init', function() {
delete_transient( WBF_UPDATE_TRANSIENT );
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;
}