Compare commits
10 Commits
680629e1f8
...
7e8d693832
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8d693832 | ||
|
|
6b24ac0071 | ||
|
|
5a85c3d31a | ||
|
|
cbe02a6caf | ||
|
|
b33773e214 | ||
|
|
435e28fd55 | ||
|
|
baf6c59c0d | ||
|
|
a004ffba9b | ||
|
|
10499e0249 | ||
|
|
4b017453b2 |
@@ -7,9 +7,23 @@
|
|||||||
|
|
||||||
⚠️ **WICHTIG:** Ändern Sie dieses Passwort sofort nach der ersten Anmeldung!
|
⚠️ **WICHTIG:** Ändern Sie dieses Passwort sofort nach der ersten Anmeldung!
|
||||||
|
|
||||||
## Passwort-Hash generieren
|
## Admin-Passwort setzen
|
||||||
|
|
||||||
Um einen neuen Benutzer oder ein neues Passwort zu erstellen, können Sie folgenden Node.js-Code verwenden:
|
Das einfachste Verfahren ist das Script `scripts/set-admin-password.js`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mit Passwort als Argument
|
||||||
|
node scripts/set-admin-password.js "mein-neues-passwort"
|
||||||
|
|
||||||
|
# Oder interaktiv (Passwort wird abgefragt)
|
||||||
|
node scripts/set-admin-password.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Siehe auch: `scripts/README-set-admin-password.md`
|
||||||
|
|
||||||
|
## Passwort-Hash generieren (manuell)
|
||||||
|
|
||||||
|
Falls Sie einen Passwort-Hash manuell generieren möchten, können Sie folgenden Node.js-Code verwenden:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
|||||||
510
DATENSCHUTZ_UEBERSICHT.md
Normal file
510
DATENSCHUTZ_UEBERSICHT.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# Übersicht: Verschlüsselung personenbezogener Daten & Security-Informationen
|
||||||
|
|
||||||
|
## 📋 Inhaltsverzeichnis
|
||||||
|
1. [Verschlüsselte Daten](#verschlüsselte-daten)
|
||||||
|
2. [Verschlüsselungstechnologie](#verschlüsselungstechnologie)
|
||||||
|
3. [Authentifizierung & Autorisierung](#authentifizierung--autorisierung)
|
||||||
|
4. [API-Endpunkte & Zugriffsschutz](#api-endpunkte--zugriffsschutz)
|
||||||
|
5. [Rollen & Berechtigungen](#rollen--berechtigungen)
|
||||||
|
6. [Session-Management](#session-management)
|
||||||
|
7. [Umgebungsvariablen](#umgebungsvariablen)
|
||||||
|
8. [Dateisystem-Struktur](#dateisystem-struktur)
|
||||||
|
9. [Externe Dependencies](#externe-dependencies)
|
||||||
|
10. [Sicherheitsrichtlinien](#sicherheitsrichtlinien)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Verschlüsselte Daten
|
||||||
|
|
||||||
|
### Kritische Dateien mit personenbezogenen Daten:
|
||||||
|
|
||||||
|
1. **users.json** - Benutzerdaten
|
||||||
|
- E-Mail-Adressen
|
||||||
|
- Namen
|
||||||
|
- Passwort-Hashes (bcrypt)
|
||||||
|
- Rollen (Array)
|
||||||
|
- Telefonnummern
|
||||||
|
- Aktivierungsstatus
|
||||||
|
|
||||||
|
2. **members.json** - Mitgliederdaten
|
||||||
|
- Namen
|
||||||
|
- E-Mail-Adressen
|
||||||
|
- Telefonnummern
|
||||||
|
- Adressen
|
||||||
|
- Geburtsdaten
|
||||||
|
- Notizen
|
||||||
|
|
||||||
|
3. **newsletter-posts.json** - Newsletter-Posts
|
||||||
|
- Empfängerlisten (E-Mail-Adressen)
|
||||||
|
- Post-Inhalte
|
||||||
|
- Metadaten
|
||||||
|
|
||||||
|
4. **newsletter-subscribers.json** - Newsletter-Abonnenten
|
||||||
|
- E-Mail-Adressen
|
||||||
|
- Namen
|
||||||
|
- Bestätigungsstatus
|
||||||
|
- Abonnement-Gruppen
|
||||||
|
- Tokens (Bestätigung, Abmeldung)
|
||||||
|
|
||||||
|
5. **sessions.json** - Session-Tokens
|
||||||
|
- User-IDs
|
||||||
|
- JWT-Tokens
|
||||||
|
- Erstellungs- und Ablaufzeiten
|
||||||
|
|
||||||
|
6. **membership-applications/*.json** - Mitgliedschaftsanträge
|
||||||
|
- Vollständige Antragsdaten (verschlüsselt als `encryptedData`)
|
||||||
|
- Persönliche Informationen
|
||||||
|
- Kontaktdaten
|
||||||
|
|
||||||
|
### ⚠️ Enthält personenbezogene Daten, aber weniger kritisch:
|
||||||
|
|
||||||
|
1. **config.json** - Konfigurationsdatei
|
||||||
|
- E-Mail-Adressen von Vorstand und Website-Verantwortlichem
|
||||||
|
- Diese sind öffentliche Kontaktdaten, die auf der Website angezeigt werden
|
||||||
|
- Könnte optional verschlüsselt werden, ist aber nicht kritisch
|
||||||
|
|
||||||
|
### ✅ Keine personenbezogenen Daten:
|
||||||
|
|
||||||
|
- **news.json** - Nur Autor-Name, keine E-Mail
|
||||||
|
- **newsletter-groups.json** - Nur Metadaten (Gruppenname, Typ, etc.)
|
||||||
|
- **galerie-metadata.json** - Keine personenbezogenen Daten
|
||||||
|
- **termine.json** - Nur öffentliche Termine
|
||||||
|
- **mannschaften.csv** - Öffentliche Mannschaftsinformationen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Verschlüsselungstechnologie
|
||||||
|
|
||||||
|
### Algorithmus & Konfiguration:
|
||||||
|
|
||||||
|
- **Algorithmus**: AES-256-CBC
|
||||||
|
- **Schlüsselableitung**: PBKDF2 mit SHA-512
|
||||||
|
- **Iterationen**: 100.000 Runden
|
||||||
|
- **Salt-Länge**: 32 Bytes (zufällig pro Verschlüsselung)
|
||||||
|
- **IV-Länge**: 16 Bytes (zufällig pro Verschlüsselung)
|
||||||
|
- **Format**: Base64-kodiert (Salt + IV + verschlüsselter Text)
|
||||||
|
|
||||||
|
### Verschlüsselungsschlüssel:
|
||||||
|
|
||||||
|
- **Umgebungsvariable**: `ENCRYPTION_KEY`
|
||||||
|
- **Standardwert (Development)**: `local_development_encryption_key_change_in_production`
|
||||||
|
- **⚠️ WICHTIG**: Muss in Produktion geändert werden!
|
||||||
|
- **Speicherort**: `.env` Datei (nicht in Git)
|
||||||
|
|
||||||
|
### Verschlüsselungsfunktionen:
|
||||||
|
|
||||||
|
- `encrypt(text, password)` - Verschlüsselt einen Text
|
||||||
|
- `decrypt(encryptedData, password)` - Entschlüsselt einen Text
|
||||||
|
- `encryptObject(obj, password)` - Verschlüsselt ein JSON-Objekt
|
||||||
|
- `decryptObject(encryptedData, password)` - Entschlüsselt ein JSON-Objekt
|
||||||
|
|
||||||
|
**Datei**: `server/utils/encryption.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Authentifizierung & Autorisierung
|
||||||
|
|
||||||
|
### Passwort-Hashing:
|
||||||
|
|
||||||
|
- **Algorithmus**: bcrypt
|
||||||
|
- **Rounds**: 10
|
||||||
|
- **Bibliothek**: `bcryptjs` v2.4.3
|
||||||
|
|
||||||
|
### JWT-Tokens:
|
||||||
|
|
||||||
|
- **Bibliothek**: `jsonwebtoken` v9.0.2
|
||||||
|
- **Algorithmus**: HS256 (HMAC SHA-256)
|
||||||
|
- **Secret**: `JWT_SECRET` aus Umgebungsvariablen
|
||||||
|
- **Standardwert (Development)**: `harheimertc-secret-key-change-in-production`
|
||||||
|
- **Gültigkeitsdauer**: 7 Tage
|
||||||
|
- **Payload**: `{ id, email, roles }`
|
||||||
|
|
||||||
|
### Cookie-Konfiguration:
|
||||||
|
|
||||||
|
- **Name**: `auth_token`
|
||||||
|
- **HttpOnly**: `true` (verhindert JavaScript-Zugriff)
|
||||||
|
- **Secure**: `false` (in Production sollte dies `true` sein, wenn HTTPS verwendet wird)
|
||||||
|
- **SameSite**: `lax`
|
||||||
|
- **MaxAge**: 7 Tage (604.800 Sekunden)
|
||||||
|
|
||||||
|
### Authentifizierungs-Endpunkte:
|
||||||
|
|
||||||
|
- `POST /api/auth/login` - Login mit E-Mail/Passwort
|
||||||
|
- `POST /api/auth/logout` - Logout (löscht Session)
|
||||||
|
- `POST /api/auth/register` - Registrierung (erfordert Admin-Freischaltung)
|
||||||
|
- `POST /api/auth/reset-password` - Passwort-Reset
|
||||||
|
- `GET /api/auth/status` - Prüft aktuellen Auth-Status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ API-Endpunkte & Zugriffsschutz
|
||||||
|
|
||||||
|
### Geschützte Routen:
|
||||||
|
|
||||||
|
- `/mitgliederbereich/*` - Erfordert Authentifizierung
|
||||||
|
- `/cms/*` - Erfordert Authentifizierung + spezifische Rollen
|
||||||
|
|
||||||
|
### Middleware:
|
||||||
|
|
||||||
|
**Datei**: `middleware/auth.js`
|
||||||
|
|
||||||
|
- Prüft Authentifizierung für geschützte Routen
|
||||||
|
- Rollenbasierte Zugriffskontrolle für CMS-Bereich
|
||||||
|
- Redirect zu `/login` bei fehlender Authentifizierung
|
||||||
|
|
||||||
|
### API-Endpunkt-Kategorien:
|
||||||
|
|
||||||
|
#### Öffentliche Endpunkte (keine Authentifizierung):
|
||||||
|
- `GET /api/news-public` - Öffentliche News
|
||||||
|
- `GET /api/termine` - Öffentliche Termine
|
||||||
|
- `GET /api/spielplaene` - Spielpläne
|
||||||
|
- `GET /api/galerie/list` - Galerie-Liste (mit Zugriffskontrolle)
|
||||||
|
- `GET /api/galerie/[id]` - Einzelbild (mit Zugriffskontrolle)
|
||||||
|
- `POST /api/newsletter/subscribe` - Newsletter-Anmeldung
|
||||||
|
- `GET /api/newsletter/confirm` - Newsletter-Bestätigung
|
||||||
|
- `GET /api/newsletter/unsubscribe` - Newsletter-Abmeldung
|
||||||
|
- `POST /api/newsletter/unsubscribe-by-email` - Abmeldung per E-Mail
|
||||||
|
- `GET /api/newsletter/groups/public-list` - Öffentliche Newsletter-Gruppen
|
||||||
|
- `GET /api/newsletter/check-subscription` - Prüft Abonnement-Status
|
||||||
|
- `POST /api/contact` - Kontaktformular
|
||||||
|
|
||||||
|
#### Authentifizierte Endpunkte (erfordern Login):
|
||||||
|
|
||||||
|
**Mitgliederbereich:**
|
||||||
|
- `GET /api/members` - Mitgliederliste (mit Rollenprüfung)
|
||||||
|
- `GET /api/profile` - Eigenes Profil
|
||||||
|
- `PUT /api/profile` - Profil bearbeiten
|
||||||
|
- `GET /api/news` - Interne News
|
||||||
|
- `POST /api/news` - News erstellen (admin/vorstand)
|
||||||
|
- `DELETE /api/news` - News löschen (admin/vorstand)
|
||||||
|
|
||||||
|
**CMS-Endpunkte (admin/vorstand):**
|
||||||
|
- `GET /api/cms/users/list` - Benutzerliste
|
||||||
|
- `POST /api/cms/users/approve` - Benutzer freischalten
|
||||||
|
- `POST /api/cms/users/update-role` - Rolle ändern
|
||||||
|
- `POST /api/cms/users/deactivate` - Benutzer deaktivieren
|
||||||
|
- `POST /api/cms/users/reject` - Registrierung ablehnen
|
||||||
|
- `PUT /api/config` - Konfiguration bearbeiten
|
||||||
|
- `POST /api/cms/save-csv` - CSV speichern
|
||||||
|
- `POST /api/cms/upload-spielplan-pdf` - Spielplan hochladen
|
||||||
|
- `POST /api/cms/satzung-upload` - Satzung hochladen
|
||||||
|
- `POST /api/members` - Mitglied hinzufügen/bearbeiten
|
||||||
|
- `DELETE /api/members` - Mitglied löschen
|
||||||
|
- `POST /api/members/bulk` - Bulk-Import
|
||||||
|
- `POST /api/personen/upload` - Personenbild hochladen
|
||||||
|
- `POST /api/galerie/upload` - Galeriebild hochladen
|
||||||
|
- `DELETE /api/galerie/[id]` - Galeriebild löschen
|
||||||
|
- `POST /api/termine-manage` - Termin erstellen/bearbeiten
|
||||||
|
- `DELETE /api/termine-manage` - Termin löschen
|
||||||
|
- `GET /api/termine-manage` - Termine verwalten
|
||||||
|
- `GET /api/membership/applications` - Mitgliedschaftsanträge
|
||||||
|
- `POST /api/membership/generate-pdf` - PDF generieren
|
||||||
|
- `GET /api/membership/download/[id]` - PDF herunterladen
|
||||||
|
- `PUT /api/membership/update-status` - Status aktualisieren
|
||||||
|
|
||||||
|
**Newsletter-Endpunkte (admin/vorstand/newsletter):**
|
||||||
|
- `GET /api/newsletter/groups/list` - Newsletter-Gruppen auflisten
|
||||||
|
- `POST /api/newsletter/groups/create` - Gruppe erstellen
|
||||||
|
- `GET /api/newsletter/groups/[id]/posts/list` - Posts auflisten
|
||||||
|
- `POST /api/newsletter/groups/[id]/posts/create` - Post erstellen und versenden
|
||||||
|
- `GET /api/newsletter/groups/[id]/subscribers/list` - Abonnenten auflisten
|
||||||
|
- `POST /api/newsletter/groups/[id]/subscribers/add` - Abonnent hinzufügen
|
||||||
|
- `POST /api/newsletter/groups/[id]/subscribers/remove` - Abonnent entfernen
|
||||||
|
|
||||||
|
### Zugriffskontrolle:
|
||||||
|
|
||||||
|
- **Helper-Funktionen**: `hasRole()`, `hasAnyRole()`, `hasAllRoles()` in `server/utils/auth.js`
|
||||||
|
- **Token-Validierung**: `getUserFromToken()` prüft JWT und lädt Benutzer
|
||||||
|
- **Rollenprüfung**: Alle geschützten Endpunkte prüfen Rollen vor Ausführung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Rollen & Berechtigungen
|
||||||
|
|
||||||
|
### Verfügbare Rollen:
|
||||||
|
|
||||||
|
1. **mitglied** (Standard)
|
||||||
|
- Zugriff auf Mitgliederbereich
|
||||||
|
- Eigene Profilansicht und -bearbeitung
|
||||||
|
- Mitgliederliste (ohne Kontaktdaten)
|
||||||
|
- Interne News lesen
|
||||||
|
|
||||||
|
2. **vorstand**
|
||||||
|
- Alle Mitglieder-Berechtigungen
|
||||||
|
- Zugriff auf Kontaktdaten in Mitgliederliste
|
||||||
|
- CMS-Zugriff (außer Benutzerverwaltung)
|
||||||
|
- News erstellen/bearbeiten/löschen
|
||||||
|
- Mitglieder verwalten
|
||||||
|
- Termine verwalten
|
||||||
|
- Galerie verwalten
|
||||||
|
- Newsletter verwalten
|
||||||
|
|
||||||
|
3. **admin**
|
||||||
|
- Alle Vorstand-Berechtigungen
|
||||||
|
- Benutzerverwaltung
|
||||||
|
- Rollenverwaltung
|
||||||
|
- Konfiguration ändern
|
||||||
|
- Mitgliedschaftsanträge verwalten
|
||||||
|
|
||||||
|
4. **newsletter**
|
||||||
|
- Newsletter-Verwaltung (Gruppen, Posts, Abonnenten)
|
||||||
|
- Kein Zugriff auf andere CMS-Funktionen
|
||||||
|
- Kein Zugriff auf Mitgliederbereich (außer über Newsletter-Seite)
|
||||||
|
|
||||||
|
### Rollensystem:
|
||||||
|
|
||||||
|
- **Multi-Rollen-Support**: Benutzer können mehrere Rollen haben
|
||||||
|
- **Datenstruktur**: `roles: string[]` (Array von Rollen)
|
||||||
|
- **Migration**: Alte `role: string` wird automatisch zu `roles: [role]` migriert
|
||||||
|
- **Rollenprüfung**: `hasAnyRole(user, 'admin', 'vorstand')` prüft, ob Benutzer eine der Rollen hat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Session-Management
|
||||||
|
|
||||||
|
### Session-Speicherung:
|
||||||
|
|
||||||
|
- **Datei**: `server/data/sessions.json` (verschlüsselt)
|
||||||
|
- **Struktur**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "session-id",
|
||||||
|
"userId": "user-id",
|
||||||
|
"token": "jwt-token",
|
||||||
|
"createdAt": "ISO-timestamp",
|
||||||
|
"expiresAt": "ISO-timestamp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session-Lebensdauer:
|
||||||
|
|
||||||
|
- **Gültigkeit**: 7 Tage
|
||||||
|
- **Ablauf**: Automatische Bereinigung abgelaufener Sessions
|
||||||
|
- **Logout**: Löscht Session aus Datei
|
||||||
|
|
||||||
|
### Session-Funktionen:
|
||||||
|
|
||||||
|
- `createSession(userId, token)` - Erstellt neue Session
|
||||||
|
- `deleteSession(token)` - Löscht Session
|
||||||
|
- `cleanExpiredSessions()` - Bereinigt abgelaufene Sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌍 Umgebungsvariablen
|
||||||
|
|
||||||
|
### Erforderliche Variablen:
|
||||||
|
|
||||||
|
**Authentifizierung:**
|
||||||
|
- `JWT_SECRET` - Secret für JWT-Token-Signierung
|
||||||
|
- Standard: `harheimertc-secret-key-change-in-production`
|
||||||
|
- ⚠️ Muss in Produktion geändert werden!
|
||||||
|
|
||||||
|
**Verschlüsselung:**
|
||||||
|
- `ENCRYPTION_KEY` - Schlüssel für Datenverschlüsselung
|
||||||
|
- Standard: `local_development_encryption_key_change_in_production`
|
||||||
|
- ⚠️ Muss in Produktion geändert werden!
|
||||||
|
|
||||||
|
**E-Mail-Versand:**
|
||||||
|
- `SMTP_HOST` - SMTP-Server (Standard: `smtp.gmail.com`)
|
||||||
|
- `SMTP_PORT` - SMTP-Port (Standard: `587`)
|
||||||
|
- `SMTP_USER` - SMTP-Benutzername
|
||||||
|
- `SMTP_PASS` - SMTP-Passwort
|
||||||
|
- `SMTP_FROM` - Absender-E-Mail (Standard: `noreply@harheimertc.de`)
|
||||||
|
|
||||||
|
**Server-Konfiguration:**
|
||||||
|
- `NODE_ENV` - Umgebung (development/production)
|
||||||
|
- `PORT` - Server-Port (Standard: `3100`)
|
||||||
|
- `NUXT_PUBLIC_BASE_URL` - Basis-URL der Anwendung
|
||||||
|
|
||||||
|
### Konfigurationsdatei:
|
||||||
|
|
||||||
|
- **Datei**: `.env` (nicht in Git)
|
||||||
|
- **Beispiel**: `env.example`
|
||||||
|
- **⚠️ WICHTIG**: `.env` muss in `.gitignore` sein!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Dateisystem-Struktur
|
||||||
|
|
||||||
|
### Datenverzeichnis:
|
||||||
|
|
||||||
|
```
|
||||||
|
server/data/
|
||||||
|
├── users.json # Verschlüsselt: Benutzerdaten
|
||||||
|
├── members.json # Verschlüsselt: Mitgliederdaten
|
||||||
|
├── sessions.json # Verschlüsselt: Session-Tokens
|
||||||
|
├── newsletter-subscribers.json # Verschlüsselt: Newsletter-Abonnenten
|
||||||
|
├── newsletter-posts.json # Verschlüsselt: Newsletter-Posts mit Empfängerlisten
|
||||||
|
├── newsletter-groups.json # Unverschlüsselt: Newsletter-Gruppen-Metadaten
|
||||||
|
├── news.json # Unverschlüsselt: News (nur Autor-Name)
|
||||||
|
├── config.json # Unverschlüsselt: Konfiguration (öffentliche Kontaktdaten)
|
||||||
|
├── termine.json # Unverschlüsselt: Öffentliche Termine
|
||||||
|
├── galerie-metadata.json # Unverschlüsselt: Galerie-Metadaten
|
||||||
|
├── membership-applications/ # Verschlüsselt: Mitgliedschaftsanträge
|
||||||
|
│ └── *.json
|
||||||
|
├── galerie/ # Nicht öffentlich zugänglich
|
||||||
|
│ ├── originals/ # Originalbilder
|
||||||
|
│ └── previews/ # Vorschaubilder (150x150px)
|
||||||
|
└── personen/ # Nicht öffentlich zugänglich
|
||||||
|
└── *.jpg/png # Personenbilder
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload-Verzeichnisse:
|
||||||
|
|
||||||
|
- **Galerie**: `server/data/galerie/originals/` und `server/data/galerie/previews/`
|
||||||
|
- **Personen**: `server/data/personen/`
|
||||||
|
- **PDFs**: `server/data/spielplaene/`, `server/data/satzung/`
|
||||||
|
|
||||||
|
### Zugriffsschutz:
|
||||||
|
|
||||||
|
- Upload-Verzeichnisse sind **nicht** direkt über Web-Server zugänglich
|
||||||
|
- Zugriff nur über API-Endpunkte mit Authentifizierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Externe Dependencies
|
||||||
|
|
||||||
|
### Sicherheitsrelevante Pakete:
|
||||||
|
|
||||||
|
**Authentifizierung & Verschlüsselung:**
|
||||||
|
- `bcryptjs` v2.4.3 - Passwort-Hashing
|
||||||
|
- `jsonwebtoken` v9.0.2 - JWT-Token-Generierung
|
||||||
|
- `crypto` (Node.js built-in) - Verschlüsselung
|
||||||
|
|
||||||
|
**E-Mail:**
|
||||||
|
- `nodemailer` v7.0.9 - E-Mail-Versand
|
||||||
|
|
||||||
|
**Datei-Upload:**
|
||||||
|
- `multer` v2.0.2 - Multipart-Form-Daten-Verarbeitung
|
||||||
|
- `sharp` v0.34.5 - Bildverarbeitung (Resize, EXIF-Korrektur)
|
||||||
|
|
||||||
|
**PDF:**
|
||||||
|
- `pdf-lib` v1.17.1 - PDF-Generierung
|
||||||
|
- `pdf-parse` v2.4.5 - PDF-Parsing
|
||||||
|
|
||||||
|
**Framework:**
|
||||||
|
- `nuxt` v4.1.3 - Vue.js Framework
|
||||||
|
- `vue` v3.5.22 - Frontend-Framework
|
||||||
|
- `pinia` v3.0.3 - State Management
|
||||||
|
|
||||||
|
### Sicherheits-Updates:
|
||||||
|
|
||||||
|
- Regelmäßige Updates empfohlen
|
||||||
|
- Prüfung auf bekannte Schwachstellen (npm audit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Sicherheitsrichtlinien
|
||||||
|
|
||||||
|
### Passwort-Richtlinien:
|
||||||
|
|
||||||
|
- **Minimale Länge**: 6 Zeichen (empfohlen: mindestens 12)
|
||||||
|
- **Hashing**: bcrypt mit 10 Runden
|
||||||
|
- **Speicherung**: Nur Hash, niemals Klartext
|
||||||
|
|
||||||
|
### Token-Sicherheit:
|
||||||
|
|
||||||
|
- **HttpOnly Cookies**: Verhindert XSS-Angriffe
|
||||||
|
- **SameSite**: `lax` verhindert CSRF-Angriffe
|
||||||
|
- **Ablaufzeit**: 7 Tage (kann angepasst werden)
|
||||||
|
|
||||||
|
### Datenverschlüsselung:
|
||||||
|
|
||||||
|
- **At-Rest-Verschlüsselung**: Alle kritischen Daten verschlüsselt
|
||||||
|
- **Schlüsselverwaltung**: Schlüssel nur in `.env`, niemals in Git
|
||||||
|
- **Schlüsselrotation**: Regelmäßige Rotation empfohlen
|
||||||
|
|
||||||
|
### API-Sicherheit:
|
||||||
|
|
||||||
|
- **Authentifizierung**: Erforderlich für alle geschützten Endpunkte
|
||||||
|
- **Autorisierung**: Rollenbasierte Zugriffskontrolle
|
||||||
|
- **Input-Validierung**: Prüfung aller Eingaben
|
||||||
|
- **Rate Limiting**: Nicht implementiert (empfohlen für Produktion)
|
||||||
|
|
||||||
|
### Best Practices:
|
||||||
|
|
||||||
|
- ✅ Passwörter werden niemals im Klartext gespeichert
|
||||||
|
- ✅ Sensitive Daten sind verschlüsselt
|
||||||
|
- ✅ JWT-Tokens haben Ablaufzeit
|
||||||
|
- ✅ HttpOnly Cookies verhindern XSS
|
||||||
|
- ✅ Rollenbasierte Zugriffskontrolle
|
||||||
|
- ⚠️ Rate Limiting fehlt (empfohlen)
|
||||||
|
- ⚠️ HTTPS sollte in Produktion verwendet werden
|
||||||
|
- ⚠️ Secure-Flag für Cookies sollte in Production `true` sein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Test-Zugangsdaten
|
||||||
|
|
||||||
|
### Standard-Admin-Account:
|
||||||
|
|
||||||
|
**⚠️ NUR FÜR ENTWICKLUNG - MUSS IN PRODUKTION GEÄNDERT WERDEN!**
|
||||||
|
|
||||||
|
- **E-Mail**: `admin@harheimertc.de`
|
||||||
|
- **Passwort**: `admin123` (Standard, sollte geändert werden)
|
||||||
|
- **Rolle**: `admin`
|
||||||
|
|
||||||
|
### Passwort ändern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/set-admin-password.js "neues-passwort"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test-Umgebung:
|
||||||
|
|
||||||
|
- **Development-Port**: `3100`
|
||||||
|
- **Base-URL**: `http://localhost:3100`
|
||||||
|
- **Standard-Secrets**: Siehe `env.example`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Hinweise für Security-Tests
|
||||||
|
|
||||||
|
### Zu testende Bereiche:
|
||||||
|
|
||||||
|
1. **Authentifizierung**
|
||||||
|
- Login/Logout-Funktionalität
|
||||||
|
- Token-Validierung
|
||||||
|
- Session-Management
|
||||||
|
- Passwort-Reset-Mechanismus
|
||||||
|
|
||||||
|
2. **Autorisierung**
|
||||||
|
- Rollenbasierte Zugriffskontrolle
|
||||||
|
- API-Endpunkt-Zugriff
|
||||||
|
- Frontend-Route-Zugriff
|
||||||
|
- Multi-Rollen-Support
|
||||||
|
|
||||||
|
3. **Verschlüsselung**
|
||||||
|
- Datenverschlüsselung/Entschlüsselung
|
||||||
|
- Schlüsselverwaltung
|
||||||
|
- Verschlüsselte Dateien
|
||||||
|
|
||||||
|
4. **Input-Validierung**
|
||||||
|
- SQL-Injection (nicht relevant, keine DB)
|
||||||
|
- XSS (Cross-Site-Scripting)
|
||||||
|
- CSRF (Cross-Site-Request-Forgery)
|
||||||
|
- File-Upload-Sicherheit
|
||||||
|
|
||||||
|
5. **Sensitive Daten**
|
||||||
|
- Passwort-Hashing
|
||||||
|
- Token-Sicherheit
|
||||||
|
- Cookie-Konfiguration
|
||||||
|
- Logging von sensiblen Daten
|
||||||
|
|
||||||
|
6. **API-Sicherheit**
|
||||||
|
- Endpunkt-Zugriffskontrolle
|
||||||
|
- Rate Limiting (fehlt)
|
||||||
|
- Input-Sanitization
|
||||||
|
- Error-Handling (keine sensiblen Informationen in Fehlermeldungen)
|
||||||
|
|
||||||
|
### Bekannte Limitierungen:
|
||||||
|
|
||||||
|
- ⚠️ Rate Limiting nicht implementiert
|
||||||
|
- ⚠️ HTTPS sollte in Produktion verwendet werden
|
||||||
|
- ⚠️ Secure-Flag für Cookies sollte in Production `true` sein
|
||||||
|
- ⚠️ Standard-Secrets müssen in Produktion geändert werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Letzte Aktualisierung**: 2024
|
||||||
|
**Verantwortlich**: Entwicklungsteam Harheimer TC
|
||||||
1
backups/users-1766060412221/users.json
Normal file
1
backups/users-1766060412221/users.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ==
|
||||||
143
components/ImageUpload.vue
Normal file
143
components/ImageUpload.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="!required" class="text-gray-500 text-xs">(optional)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="imageFilename" class="mb-2">
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${imageFilename}?width=100&height=100`"
|
||||||
|
:alt="label"
|
||||||
|
class="w-24 h-24 object-cover rounded-lg border-2 border-gray-300"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="!uploading"
|
||||||
|
@click="removeImage"
|
||||||
|
class="absolute -top-2 -right-2 bg-red-600 text-white rounded-full p-1 hover:bg-red-700 transition-colors"
|
||||||
|
type="button"
|
||||||
|
title="Bild entfernen"
|
||||||
|
>
|
||||||
|
<X :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
:class="[
|
||||||
|
'flex-1 px-4 py-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors',
|
||||||
|
uploading ? 'border-gray-300 bg-gray-50 cursor-not-allowed' :
|
||||||
|
dragOver ? 'border-primary-500 bg-primary-50' :
|
||||||
|
'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="uploading"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
<div class="text-center">
|
||||||
|
<div v-if="uploading" class="flex items-center justify-center gap-2 text-gray-600">
|
||||||
|
<Loader2 :size="16" class="animate-spin" />
|
||||||
|
<span>Wird hochgeladen...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-600">
|
||||||
|
<span v-if="!imageFilename">📷 Bild auswählen oder hier ablegen</span>
|
||||||
|
<span v-else>🔄 Bild ändern</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Loader2, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Bild'
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const imageFilename = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
async function handleFileSelect(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
await uploadImage(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file) {
|
||||||
|
if (!file.type.match(/^image\/(jpeg|jpg|png|gif|webp)$/)) {
|
||||||
|
error.value = 'Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
error.value = 'Bild darf maximal 10MB groß sein'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', file)
|
||||||
|
|
||||||
|
const response = await fetch('/api/personen/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ statusMessage: 'Fehler beim Hochladen' }))
|
||||||
|
throw new Error(errorData.statusMessage || 'Fehler beim Hochladen')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
emit('update:modelValue', data.filename)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || 'Fehler beim Hochladen des Bildes'
|
||||||
|
console.error('Upload error:', err)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -91,23 +91,18 @@ const mannschaften = ref([])
|
|||||||
|
|
||||||
const loadMannschaften = async () => {
|
const loadMannschaften = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Lade Mannschaften...')
|
|
||||||
const response = await fetch('/data/mannschaften.csv')
|
const response = await fetch('/data/mannschaften.csv')
|
||||||
console.log('Response:', response)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const csv = await response.text()
|
const csv = await response.text()
|
||||||
console.log('CSV Text:', csv)
|
|
||||||
|
|
||||||
// Vereinfachter CSV-Parser
|
// Vereinfachter CSV-Parser
|
||||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||||
console.log('CSV Lines:', lines)
|
|
||||||
|
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
console.log('Keine Datenzeilen gefunden')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +127,6 @@ const loadMannschaften = async () => {
|
|||||||
values.push(current.trim())
|
values.push(current.trim())
|
||||||
|
|
||||||
if (values.length < 10) {
|
if (values.length < 10) {
|
||||||
console.log(`Zeile ${index + 2} hat zu wenige Werte:`, values)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +143,8 @@ const loadMannschaften = async () => {
|
|||||||
letzte_aktualisierung: values[9] ? values[9].trim() : ''
|
letzte_aktualisierung: values[9] ? values[9].trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Mannschaft ${index + 1}:`, mannschaft)
|
|
||||||
console.log(`Parsed values count: ${values.length}`)
|
|
||||||
console.log(`Letzte Aktualisierung raw: "${values[9]}"`)
|
|
||||||
console.log(`Letzte Aktualisierung trimmed: "${values[9] ? values[9].trim() : 'undefined'}")`)
|
|
||||||
return mannschaft
|
return mannschaft
|
||||||
}).filter(mannschaft => mannschaft !== null)
|
}).filter(mannschaft => mannschaft !== null)
|
||||||
|
|
||||||
console.log('Alle geparsten Mannschaften:', mannschaften.value)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Mannschaften:', error)
|
console.error('Fehler beim Laden der Mannschaften:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,11 +53,17 @@
|
|||||||
Termine
|
Termine
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink v-if="hasGalleryImages" to="/galerie" @click="currentSubmenu = null"
|
<NuxtLink v-if="hasGalleryImages" to="/verein/galerie" @click="currentSubmenu = null"
|
||||||
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
||||||
active-class="text-white bg-primary-600">
|
active-class="text-white bg-primary-600">
|
||||||
Galerie
|
Galerie
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<button @click="toggleSubmenu('newsletter')"
|
||||||
|
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
||||||
|
:class="(route.path.startsWith('/newsletter') || currentSubmenu === 'newsletter') ? 'text-white bg-primary-600' : ''">
|
||||||
|
Newsletter
|
||||||
|
</button>
|
||||||
|
|
||||||
<button v-if="isLoggedIn" @click="toggleSubmenu('intern')"
|
<button v-if="isLoggedIn" @click="toggleSubmenu('intern')"
|
||||||
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
||||||
@@ -73,6 +79,19 @@
|
|||||||
|
|
||||||
<div class="hidden lg:flex items-center h-6 border-t border-primary-700/20">
|
<div class="hidden lg:flex items-center h-6 border-t border-primary-700/20">
|
||||||
<div v-if="currentSubmenu" class="flex items-center space-x-1">
|
<div v-if="currentSubmenu" class="flex items-center space-x-1">
|
||||||
|
<!-- Newsletter Submenu -->
|
||||||
|
<template v-if="currentSubmenu === 'newsletter'">
|
||||||
|
<NuxtLink to="/newsletter/subscribe"
|
||||||
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
|
active-class="text-white bg-primary-600">
|
||||||
|
Abonnieren
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/newsletter/unsubscribe"
|
||||||
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
|
active-class="text-white bg-primary-600">
|
||||||
|
Abmelden
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
<!-- Verein Submenu -->
|
<!-- Verein Submenu -->
|
||||||
<template v-if="currentSubmenu === 'verein'">
|
<template v-if="currentSubmenu === 'verein'">
|
||||||
<NuxtLink to="/verein/ueber-uns"
|
<NuxtLink to="/verein/ueber-uns"
|
||||||
@@ -188,6 +207,14 @@
|
|||||||
active-class="text-white bg-primary-600">
|
active-class="text-white bg-primary-600">
|
||||||
API-Dokumentation
|
API-Dokumentation
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<template v-if="canAccessNewsletter">
|
||||||
|
<div class="h-3 w-px bg-primary-700" />
|
||||||
|
<NuxtLink to="/cms/newsletter"
|
||||||
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
|
active-class="text-white bg-primary-600">
|
||||||
|
Newsletter
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<div class="h-3 w-px bg-primary-700" />
|
<div class="h-3 w-px bg-primary-700" />
|
||||||
<div class="relative inline-block">
|
<div class="relative inline-block">
|
||||||
@@ -323,6 +350,14 @@
|
|||||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
Galerie
|
Galerie
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/newsletter/subscribe" @click="isMobileMenuOpen = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
|
Newsletter abonnieren
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/newsletter/unsubscribe" @click="isMobileMenuOpen = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
|
Newsletter abmelden
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -395,10 +430,18 @@
|
|||||||
Termine
|
Termine
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink v-if="hasGalleryImages" to="/galerie" @click="isMobileMenuOpen = false"
|
<NuxtLink v-if="hasGalleryImages" to="/verein/galerie" @click="isMobileMenuOpen = false"
|
||||||
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
|
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
|
||||||
Galerie
|
Galerie
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/newsletter/subscribe" @click="isMobileMenuOpen = false"
|
||||||
|
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
|
||||||
|
Newsletter abonnieren
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/newsletter/unsubscribe" @click="isMobileMenuOpen = false"
|
||||||
|
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
|
||||||
|
Newsletter abmelden
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Intern Mobile -->
|
<!-- Intern Mobile -->
|
||||||
<div v-if="isLoggedIn">
|
<div v-if="isLoggedIn">
|
||||||
@@ -425,6 +468,13 @@
|
|||||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
Mein Profil
|
Mein Profil
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<template v-if="canAccessNewsletter">
|
||||||
|
<div class="border-t border-primary-700/20 my-2" />
|
||||||
|
<NuxtLink to="/cms/newsletter" @click="isMobileMenuOpen = false"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||||
|
Newsletter
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<div class="border-t border-primary-700/20 my-2" />
|
<div class="border-t border-primary-700/20 my-2" />
|
||||||
<NuxtLink to="/cms" @click="isMobileMenuOpen = false"
|
<NuxtLink to="/cms" @click="isMobileMenuOpen = false"
|
||||||
@@ -509,6 +559,7 @@ const showCmsDropdown = ref(false)
|
|||||||
// Reactive auth state from store
|
// Reactive auth state from store
|
||||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||||
const isAdmin = computed(() => authStore.isAdmin)
|
const isAdmin = computed(() => authStore.isAdmin)
|
||||||
|
const canAccessNewsletter = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'newsletter'))
|
||||||
|
|
||||||
// Automatisches Setzen des Submenus basierend auf der Route
|
// Automatisches Setzen des Submenus basierend auf der Route
|
||||||
const currentSubmenu = computed(() => {
|
const currentSubmenu = computed(() => {
|
||||||
@@ -526,6 +577,9 @@ const currentSubmenu = computed(() => {
|
|||||||
if (path.startsWith('/mitgliederbereich') || path.startsWith('/cms')) {
|
if (path.startsWith('/mitgliederbereich') || path.startsWith('/cms')) {
|
||||||
return 'intern'
|
return 'intern'
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('/newsletter')) {
|
||||||
|
return 'newsletter'
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -613,7 +667,9 @@ const toggleSubmenu = (menu) => {
|
|||||||
// Wenn nicht, zur Hauptseite navigieren
|
// Wenn nicht, zur Hauptseite navigieren
|
||||||
const path = route.path
|
const path = route.path
|
||||||
|
|
||||||
if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften')) {
|
if (menu === 'newsletter' && !path.startsWith('/newsletter')) {
|
||||||
|
navigateTo('/newsletter/subscribe')
|
||||||
|
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften')) {
|
||||||
navigateTo('/verein/ueber-uns')
|
navigateTo('/verein/ueber-uns')
|
||||||
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
|
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
|
||||||
navigateTo('/mannschaften')
|
navigateTo('/mannschaften')
|
||||||
|
|||||||
35
components/PersonCard.vue
Normal file
35
components/PersonCard.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||||
|
<div v-if="imageFilename" class="mb-4 flex justify-center">
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${imageFilename}?width=200&height=200`"
|
||||||
|
:alt="`${title}: ${name}`"
|
||||||
|
class="w-32 h-32 object-cover rounded-full border-4 border-primary-100 shadow-md"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 v-if="title" class="text-xl font-display font-bold text-gray-900 mb-2">{{ title }}</h3>
|
||||||
|
<h4 class="text-lg font-semibold text-primary-600 mb-3">{{ name }}</h4>
|
||||||
|
<div class="space-y-1 text-gray-600">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
imageFilename: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
147
components/RichTextEditor.vue
Normal file
147
components/RichTextEditor.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div ref="editorContainer" class="border border-gray-300 rounded-lg bg-white"></div>
|
||||||
|
<input type="hidden" :value="modelValue" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const editorContainer = ref(null)
|
||||||
|
let quill = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (process.client && editorContainer.value) {
|
||||||
|
// Dynamisch Quill nur im Client laden
|
||||||
|
const Quill = (await import('quill')).default
|
||||||
|
await import('quill/dist/quill.snow.css')
|
||||||
|
|
||||||
|
quill = new Quill(editorContainer.value, {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image'],
|
||||||
|
['blockquote', 'code-block'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
placeholder: 'Newsletter-Inhalt eingeben...'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setze initialen Inhalt
|
||||||
|
if (props.modelValue) {
|
||||||
|
quill.root.innerHTML = props.modelValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emitiere Änderungen
|
||||||
|
quill.on('text-change', () => {
|
||||||
|
const html = quill.root.innerHTML
|
||||||
|
// Prüfe ob Inhalt wirklich geändert wurde (nicht nur leere Tags)
|
||||||
|
const textContent = quill.getText().trim()
|
||||||
|
if (textContent || html !== '<p><br></p>') {
|
||||||
|
emit('update:modelValue', html)
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
if (quill && quill.root.innerHTML !== newValue) {
|
||||||
|
// Temporär Event-Listener entfernen um Endlosschleife zu vermeiden
|
||||||
|
const currentContent = quill.root.innerHTML
|
||||||
|
if (currentContent !== newValue) {
|
||||||
|
quill.root.innerHTML = newValue || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (quill) {
|
||||||
|
quill = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Quill Editor Styles */
|
||||||
|
.ql-container {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-editor {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-editor.ql-blank::before {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-toolbar {
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-container {
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-stroke {
|
||||||
|
stroke: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-fill {
|
||||||
|
fill: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker-label {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-tooltip {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-tooltip input[type=text] {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-tooltip a.ql-action::after {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -49,7 +49,6 @@ const termine = ref([])
|
|||||||
|
|
||||||
const naechsteTermine = computed(() => {
|
const naechsteTermine = computed(() => {
|
||||||
const heute = new Date()
|
const heute = new Date()
|
||||||
console.log('Heute ist:', heute.toISOString().split('T')[0])
|
|
||||||
|
|
||||||
const kommende = termine.value
|
const kommende = termine.value
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
@@ -61,9 +60,7 @@ const naechsteTermine = computed(() => {
|
|||||||
} else {
|
} else {
|
||||||
terminDatum = new Date(t.datum)
|
terminDatum = new Date(t.datum)
|
||||||
}
|
}
|
||||||
const istKommend = terminDatum >= heute
|
return terminDatum >= heute
|
||||||
console.log(`Termin ${t.titel} (${t.datum}): ${istKommend ? 'KOMMEND' : 'VERSTRICHEN'}`)
|
|
||||||
return istKommend
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const da = new Date(`${a.datum}${a.uhrzeit ? 'T' + a.uhrzeit + ':00' : ''}`)
|
const da = new Date(`${a.datum}${a.uhrzeit ? 'T' + a.uhrzeit + ':00' : ''}`)
|
||||||
@@ -71,7 +68,6 @@ const naechsteTermine = computed(() => {
|
|||||||
return da - db
|
return da - db
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Kommende Termine:', kommende)
|
|
||||||
return kommende
|
return kommende
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,20 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||||||
|
|
||||||
// Check role for CMS
|
// Check role for CMS
|
||||||
if (to.path.startsWith('/cms')) {
|
if (to.path.startsWith('/cms')) {
|
||||||
const isAdmin = auth.value.role === 'admin' || auth.value.role === 'vorstand'
|
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
|
||||||
if (!isAdmin) {
|
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
|
||||||
return navigateTo('/mitgliederbereich')
|
|
||||||
|
// Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand
|
||||||
|
if (to.path.startsWith('/cms/newsletter')) {
|
||||||
|
if (!hasAccess) {
|
||||||
|
return navigateTo('/mitgliederbereich')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Andere CMS-Seiten nur für Admin oder Vorstand
|
||||||
|
const isAdmin = roles.includes('admin') || roles.includes('vorstand')
|
||||||
|
if (!isAdmin) {
|
||||||
|
return navigateTo('/mitgliederbereich')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
90
package-lock.json
generated
90
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
|
"@tinymce/tinymce-vue": "^6.3.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
@@ -18,7 +19,9 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"quill": "^2.0.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"tinymce": "^8.3.1",
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3827,6 +3830,21 @@
|
|||||||
"integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==",
|
"integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tinymce/tinymce-vue": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-vue/-/tinymce-vue-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-DSP8Jhd3XqCCliTnusfbmz3D8GqQ4iRzkc4aadYHDcJPVjkaqopJ61McOdH82CSy599vGLkPjGzqJYWJkRMiUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1",
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"tinymce": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -6368,6 +6386,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@@ -6425,6 +6449,12 @@
|
|||||||
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
|
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-fifo": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
@@ -7970,6 +8000,18 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||||
|
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
@@ -7994,6 +8036,13 @@
|
|||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isinteger": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
@@ -9094,6 +9143,12 @@
|
|||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/parse-ms": {
|
"node_modules/parse-ms": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
|
||||||
@@ -10118,6 +10173,35 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/radix3": {
|
"node_modules/radix3": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
||||||
@@ -11665,6 +11749,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinymce": {
|
||||||
|
"version": "8.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.3.1.tgz",
|
||||||
|
"integrity": "sha512-mdQdTAA90aEIyhEteIwy+QQ6UnxPCd3qQ5MlGvvByOvnjyOSdBzBcmnXeqWuhGz3fIs3XBJjIw7JyIMiHjebqw==",
|
||||||
|
"license": "SEE LICENSE IN license.md"
|
||||||
|
},
|
||||||
"node_modules/tinypool": {
|
"node_modules/tinypool": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
|
"@tinymce/tinymce-vue": "^6.3.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
@@ -24,7 +25,9 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"quill": "^2.0.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"tinymce": "^8.3.1",
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<option value="mitglied">Mitglied</option>
|
<option value="mitglied">Mitglied</option>
|
||||||
<option value="vorstand">Vorstand</option>
|
<option value="vorstand">Vorstand</option>
|
||||||
<option value="admin">Administrator</option>
|
<option value="admin">Administrator</option>
|
||||||
|
<option value="newsletter">Newsletter</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Approve Button -->
|
<!-- Approve Button -->
|
||||||
@@ -112,20 +113,27 @@
|
|||||||
<div class="text-sm text-gray-600">{{ user.phone || '-' }}</div>
|
<div class="text-sm text-gray-600">{{ user.phone || '-' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<select
|
<div class="flex flex-wrap gap-1">
|
||||||
v-model="user.role"
|
<span
|
||||||
@change="updateUserRole(user)"
|
v-for="role in (user.roles || (user.role ? [user.role] : ['mitglied']))"
|
||||||
class="px-3 py-1 border border-gray-300 rounded text-sm"
|
:key="role"
|
||||||
:class="{
|
class="px-2 py-1 text-xs font-medium rounded"
|
||||||
'bg-red-50 border-red-300': user.role === 'admin',
|
:class="{
|
||||||
'bg-blue-50 border-blue-300': user.role === 'vorstand',
|
'bg-red-100 text-red-800': role === 'admin',
|
||||||
'bg-gray-50 border-gray-300': user.role === 'mitglied'
|
'bg-blue-100 text-blue-800': role === 'vorstand',
|
||||||
}"
|
'bg-green-100 text-green-800': role === 'newsletter',
|
||||||
|
'bg-gray-100 text-gray-800': role === 'mitglied'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : 'Mitglied' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="openRoleModal(user)"
|
||||||
|
class="mt-1 text-xs text-primary-600 hover:text-primary-800"
|
||||||
>
|
>
|
||||||
<option value="mitglied">Mitglied</option>
|
Bearbeiten
|
||||||
<option value="vorstand">Vorstand</option>
|
</button>
|
||||||
<option value="admin">Administrator</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
@@ -162,6 +170,79 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Edit Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showRoleModal && editingUser"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closeRoleModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
|
||||||
|
Rollen bearbeiten: {{ editingUser.name }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedRoles"
|
||||||
|
value="mitglied"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Mitglied</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedRoles"
|
||||||
|
value="vorstand"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Vorstand</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedRoles"
|
||||||
|
value="newsletter"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Newsletter</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedRoles"
|
||||||
|
value="admin"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Administrator</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedRoles.length === 0" class="mb-4 text-sm text-red-600">
|
||||||
|
Mindestens eine Rolle muss ausgewählt werden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeRoleModal"
|
||||||
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="saveUserRoles"
|
||||||
|
:disabled="selectedRoles.length === 0"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -173,11 +254,17 @@ const allUsers = ref([])
|
|||||||
const currentUserId = ref(null)
|
const currentUserId = ref(null)
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const showRoleModal = ref(false)
|
||||||
|
const editingUser = ref(null)
|
||||||
|
const selectedRoles = ref([])
|
||||||
|
|
||||||
const pendingUsers = computed(() => {
|
const pendingUsers = computed(() => {
|
||||||
return allUsers.value
|
return allUsers.value
|
||||||
.filter(u => u.active === false)
|
.filter(u => u.active === false)
|
||||||
.map(u => ({ ...u, selectedRole: u.role || 'mitglied' }))
|
.map(u => ({
|
||||||
|
...u,
|
||||||
|
selectedRole: (u.roles && u.roles.length > 0) ? u.roles[0] : (u.role || 'mitglied')
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeUsers = computed(() => {
|
const activeUsers = computed(() => {
|
||||||
@@ -210,7 +297,7 @@ const approveUser = async (user) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: user.selectedRole
|
roles: [user.selectedRole || 'mitglied']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -224,6 +311,41 @@ const approveUser = async (user) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRoleModal(user) {
|
||||||
|
editingUser.value = user
|
||||||
|
selectedRoles.value = user.roles || (user.role ? [user.role] : ['mitglied'])
|
||||||
|
showRoleModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRoleModal() {
|
||||||
|
showRoleModal.value = false
|
||||||
|
editingUser.value = null
|
||||||
|
selectedRoles.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserRoles() {
|
||||||
|
if (!editingUser.value || selectedRoles.value.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/cms/users/update-role', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
userId: editingUser.value.id,
|
||||||
|
roles: selectedRoles.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successMessage.value = `Rollen von ${editingUser.value.name} wurden aktualisiert`
|
||||||
|
setTimeout(() => successMessage.value = '', 3000)
|
||||||
|
|
||||||
|
closeRoleModal()
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = 'Fehler beim Aktualisieren der Rollen'
|
||||||
|
setTimeout(() => errorMessage.value = '', 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rejectUser = async (user) => {
|
const rejectUser = async (user) => {
|
||||||
window.showConfirmModal('Registrierung ablehnen', `Möchten Sie die Registrierung von ${user.name} wirklich ablehnen?`, async () => {
|
window.showConfirmModal('Registrierung ablehnen', `Möchten Sie die Registrierung von ${user.name} wirklich ablehnen?`, async () => {
|
||||||
try {
|
try {
|
||||||
@@ -241,24 +363,6 @@ const rejectUser = async (user) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUserRole = async (user) => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/cms/users/update-role', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
userId: user.id,
|
|
||||||
role: user.role
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
successMessage.value = `Rolle von ${user.name} wurde aktualisiert`
|
|
||||||
setTimeout(() => successMessage.value = '', 3000)
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = 'Fehler beim Aktualisieren der Rolle'
|
|
||||||
setTimeout(() => errorMessage.value = '', 3000)
|
|
||||||
await loadUsers() // Reload to revert changes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deactivateUser = async (user) => {
|
const deactivateUser = async (user) => {
|
||||||
window.showConfirmModal('Benutzer deaktivieren', `Möchten Sie ${user.name} wirklich deaktivieren?`, async () => {
|
window.showConfirmModal('Benutzer deaktivieren', `Möchten Sie ${user.name} wirklich deaktivieren?`, async () => {
|
||||||
|
|||||||
@@ -211,22 +211,31 @@
|
|||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Gruppe</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Gruppe</label>
|
||||||
<div class="flex space-x-2">
|
<input
|
||||||
<input
|
v-model="zeit.gruppe"
|
||||||
v-model="zeit.gruppe"
|
type="text"
|
||||||
type="text"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
/>
|
||||||
/>
|
</div>
|
||||||
<button
|
</div>
|
||||||
@click="removeTrainingTime(index)"
|
<div class="mt-4">
|
||||||
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
<label class="block text-sm font-medium text-gray-700 mb-2">Zusätzliche Information (optional)</label>
|
||||||
title="Löschen"
|
<div class="flex space-x-2">
|
||||||
>
|
<input
|
||||||
<Trash2 :size="18" />
|
v-model="zeit.info"
|
||||||
</button>
|
type="text"
|
||||||
</div>
|
placeholder="z.B. 'Nur in der Schulzeit', 'Ab 10 Jahren', etc."
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="removeTrainingTime(index)"
|
||||||
|
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,6 +304,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<ImageUpload
|
||||||
|
v-model="trainer.imageFilename"
|
||||||
|
label="Foto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,6 +474,12 @@
|
|||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<ImageUpload
|
||||||
|
v-model="position.imageFilename"
|
||||||
|
label="Foto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -572,7 +593,8 @@ const addTrainingTime = () => {
|
|||||||
tag: 'Montag',
|
tag: 'Montag',
|
||||||
von: '19:00',
|
von: '19:00',
|
||||||
bis: '22:00',
|
bis: '22:00',
|
||||||
gruppe: `Gruppe ${naechsteGruppeNummer}`
|
gruppe: `Gruppe ${naechsteGruppeNummer}`,
|
||||||
|
info: ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
|
|
||||||
<!-- Benutzerverwaltung (nur für Admin) -->
|
<!-- Benutzerverwaltung (nur für Admin) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="authStore.role === 'admin'"
|
v-if="authStore.hasRole('admin')"
|
||||||
to="/cms/benutzer"
|
to="/cms/benutzer"
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||||
>
|
>
|
||||||
|
|||||||
890
pages/cms/newsletter.vue
Normal file
890
pages/cms/newsletter.vue
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full bg-gray-50">
|
||||||
|
<!-- Fixed Header -->
|
||||||
|
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl sm:text-4xl font-display font-bold text-gray-900">
|
||||||
|
Newsletter
|
||||||
|
</h1>
|
||||||
|
<div class="w-16 sm:w-24 h-1 bg-primary-600 mt-1 sm:mt-2" />
|
||||||
|
</div>
|
||||||
|
<div class="space-x-3">
|
||||||
|
<button
|
||||||
|
v-if="canCreateGroup"
|
||||||
|
@click="showCreateGroupModal = true"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
<Plus :size="16" class="mr-2" />
|
||||||
|
Neue Newsletter-Gruppe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="pt-28 sm:pt-32 pb-16">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||||
|
<Loader2 :size="40" class="animate-spin text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Newsletter Groups List -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Group Header -->
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">{{ group.name }}</h3>
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{{ group.type === 'subscription' ? 'Abonnenten' : 'Gruppe' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="group.description" class="text-sm text-gray-600 mb-2">
|
||||||
|
{{ group.description }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||||
|
<span>Erstellt: {{ formatDate(group.createdAt) }}</span>
|
||||||
|
<span>{{ group.postCount || 0 }} Posts</span>
|
||||||
|
<span v-if="group.type === 'group'">
|
||||||
|
Zielgruppe: {{ formatTargetGroup(group.targetGroup) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="group.type === 'subscription'">
|
||||||
|
{{ group.sendToExternal ? 'Intern & Extern' : 'Nur Intern' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
|
<button
|
||||||
|
v-if="group.type === 'subscription'"
|
||||||
|
@click="showSubscribersModal(group)"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Users :size="16" class="inline mr-1" />
|
||||||
|
Abonnenten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showPostModal(group)"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus :size="16" class="inline mr-1" />
|
||||||
|
Post hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts List Header -->
|
||||||
|
<div v-if="groupPosts[group.id] && groupPosts[group.id].length > 0" class="border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
@click="toggleGroupPosts(group.id)"
|
||||||
|
class="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-700">
|
||||||
|
Posts ({{ groupPosts[group.id].length }})
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
:class="['w-5 h-5 text-gray-500 transition-transform', expandedGroups[group.id] ? 'rotate-180' : '']"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Collapsible Posts List -->
|
||||||
|
<div v-show="expandedGroups[group.id]" class="divide-y divide-gray-200">
|
||||||
|
<div
|
||||||
|
v-for="post in groupPosts[group.id]"
|
||||||
|
:key="post.id"
|
||||||
|
class="p-6 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 mb-2">{{ post.title }}</h4>
|
||||||
|
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-3">
|
||||||
|
<span v-if="post.sentAt">Versendet: {{ formatDate(post.sentAt) }}</span>
|
||||||
|
<span v-else class="text-yellow-600">Nicht versendet</span>
|
||||||
|
<span v-if="post.sentTo && post.sentTo.total > 0">
|
||||||
|
Empfänger: {{ post.sentTo.sent }}/{{ post.sentTo.total }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="post.sentTo && post.sentTo.total === 0" class="text-gray-400">
|
||||||
|
Keine Empfänger gefunden
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="post.content.substring(0, 200) + (post.content.length > 200 ? '...' : '')"
|
||||||
|
class="text-sm text-gray-600 prose prose-sm max-w-none mb-3"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Empfängerliste (collapsible) -->
|
||||||
|
<div v-if="post.sentTo && post.sentTo.recipients && post.sentTo.recipients.length > 0" class="border-t border-gray-200 mt-3 pt-3">
|
||||||
|
<button
|
||||||
|
@click="togglePostRecipients(post.id)"
|
||||||
|
class="w-full flex items-center justify-between text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="font-medium">
|
||||||
|
Empfänger ({{ post.sentTo.recipients.length }})
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
:class="['w-4 h-4 transition-transform', expandedPosts[post.id] ? 'rotate-180' : '']"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-show="expandedPosts[post.id]" class="mt-3 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(recipient, idx) in post.sentTo.recipients"
|
||||||
|
:key="idx"
|
||||||
|
class="flex items-center justify-between text-sm py-1 px-2 rounded"
|
||||||
|
:class="recipient.sent ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{{ recipient.email }}</span>
|
||||||
|
<span v-if="recipient.name" class="text-gray-600 ml-2">({{ recipient.name }})</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ recipient.sent ? '✓ Versendet' : '✗ Fehler' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="post.sentTo && post.sentTo.total === 0" class="border-t border-gray-200 mt-3 pt-3 text-sm text-gray-500">
|
||||||
|
Keine Empfänger gefunden
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-6 text-center text-gray-500 text-sm border-t border-gray-200">
|
||||||
|
Noch keine Posts in dieser Gruppe
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groups.length === 0" class="text-center py-12 text-gray-500">
|
||||||
|
Noch keine Newsletter-Gruppen vorhanden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Group Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showCreateGroupModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closeGroupModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Neue Newsletter-Gruppe erstellen
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
<form id="group-form" @submit.prevent="saveGroup" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="groupFormData.name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="z.B. Allgemeiner Newsletter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="groupFormData.description"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Beschreibung der Newsletter-Gruppe"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Typ *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="groupFormData.type"
|
||||||
|
required
|
||||||
|
@change="onGroupTypeChange"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="subscription">Abonnenten-Newsletter</option>
|
||||||
|
<option value="group">Gruppen-Newsletter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupFormData.type === 'subscription'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Empfänger
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="groupFormData.sendToExternal"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option :value="false">Nur Intern</option>
|
||||||
|
<option :value="true">Auch Extern</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupFormData.type === 'group'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Zielgruppe *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="groupFormData.targetGroup"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="alle">Alle</option>
|
||||||
|
<option value="erwachsene">Erwachsene</option>
|
||||||
|
<option value="nachwuchs">Nachwuchs</option>
|
||||||
|
<option value="mannschaftsspieler">Mannschaftsspieler</option>
|
||||||
|
<option value="vorstand">Vorstand</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeGroupModal"
|
||||||
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="group-form"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Post Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showPostModalForGroup"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closePostModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Post zu "{{ showPostModalForGroup.name }}" hinzufügen
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Der Post wird automatisch an alle Abonnenten dieser Gruppe versendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
<form id="post-form" @submit.prevent="savePost" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Titel *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="postFormData.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Post-Titel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<RichTextEditor
|
||||||
|
v-model="postFormData.content"
|
||||||
|
label="Inhalt *"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
||||||
|
<!-- Erfolgsmeldung -->
|
||||||
|
<div v-if="postSuccessMessage" class="space-y-4">
|
||||||
|
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<p class="text-sm font-medium text-green-800">
|
||||||
|
{{ postSuccessMessage }}
|
||||||
|
</p>
|
||||||
|
<div v-if="postSuccessStats" class="mt-2 text-sm text-green-700">
|
||||||
|
<p>Empfänger: {{ postSuccessStats.sent }}/{{ postSuccessStats.total }} erfolgreich versendet</p>
|
||||||
|
<div v-if="postSuccessStats.failed > 0" class="mt-2">
|
||||||
|
<p class="font-medium">⚠️ {{ postSuccessStats.failed }} Fehler beim Versenden:</p>
|
||||||
|
<ul v-if="postSuccessStats.errorDetails" class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li v-for="err in postSuccessStats.errorDetails" :key="err.email">
|
||||||
|
{{ err.email }}: {{ err.error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else-if="postSuccessStats.failedEmails" class="mt-1">
|
||||||
|
{{ postSuccessStats.failedEmails.join(', ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="closePostModal"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formular-Buttons -->
|
||||||
|
<div v-else class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closePostModal"
|
||||||
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="post-form"
|
||||||
|
:disabled="isSendingPost"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{{ isSendingPost ? 'Wird versendet...' : 'Erstellen & Versenden' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscribers Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showSubscribersModalForGroup"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closeSubscribersModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||||
|
Abonnenten: {{ showSubscribersModalForGroup.name }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="showAddSubscriberModal = true"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm flex items-center"
|
||||||
|
>
|
||||||
|
<Plus :size="16" class="mr-2" />
|
||||||
|
Empfänger hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
<div v-if="isLoadingSubscribers" class="flex items-center justify-center py-12">
|
||||||
|
<Loader2 :size="40" class="animate-spin text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="subscribers.length === 0" class="text-center py-12 text-gray-500">
|
||||||
|
Keine Abonnenten gefunden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
<strong>{{ subscribers.length }}</strong> Abonnent{{ subscribers.length !== 1 ? 'en' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
E-Mail
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Angemeldet
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="subscriber in subscribers" :key="subscriber.id" class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ subscriber.email }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||||
|
{{ subscriber.name || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
subscriber.confirmed && !subscriber.unsubscribedAt
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: subscriber.unsubscribedAt
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
subscriber.confirmed && !subscriber.unsubscribedAt
|
||||||
|
? 'Bestätigt'
|
||||||
|
: subscriber.unsubscribedAt
|
||||||
|
? 'Abgemeldet'
|
||||||
|
: 'Ausstehend'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||||
|
{{ formatDate(subscriber.subscribedAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button
|
||||||
|
@click="removeSubscriber(subscriber.id)"
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
title="Abonnent entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border-t border-gray-200 flex justify-end flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeSubscribersModal"
|
||||||
|
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Subscriber Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showAddSubscriberModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closeAddSubscriberModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||||
|
Empfänger hinzufügen: {{ showSubscribersModalForGroup?.name }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Der Empfänger erhält eine Bestätigungsmail mit Ihrer individuellen Nachricht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
<form id="add-subscriber-form" @submit.prevent="addSubscriber" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
E-Mail-Adresse *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="addSubscriberForm.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="empfaenger@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Name (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="addSubscriberForm.name"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Name des Empfängers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Individuelle Nachricht (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="addSubscriberForm.customMessage"
|
||||||
|
rows="4"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Diese Nachricht wird in der Bestätigungsmail angezeigt..."
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Diese Nachricht wird in der Bestätigungsmail angezeigt, um den Empfänger persönlich anzusprechen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="addSubscriberError" class="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{{ addSubscriberError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="addSubscriberSuccess" class="p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
||||||
|
{{ addSubscriberSuccess }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeAddSubscriberModal"
|
||||||
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
:disabled="isAddingSubscriber"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="add-subscriber-form"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center disabled:opacity-50"
|
||||||
|
:disabled="isAddingSubscriber"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isAddingSubscriber" :size="16" class="animate-spin mr-2" />
|
||||||
|
<span>{{ isAddingSubscriber ? 'Wird hinzugefügt...' : 'Hinzufügen' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Plus, Loader2, Users, Trash2 } from 'lucide-vue-next'
|
||||||
|
import RichTextEditor from '~/components/RichTextEditor.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Newsletter-Verwaltung - CMS - Harheimer TC',
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = ref([])
|
||||||
|
const groupPosts = ref({})
|
||||||
|
const expandedGroups = ref({}) // Track which groups have expanded posts
|
||||||
|
const expandedPosts = ref({}) // Track which posts have expanded recipients
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const showCreateGroupModal = ref(false)
|
||||||
|
const showPostModalForGroup = ref(null)
|
||||||
|
const isSendingPost = ref(false)
|
||||||
|
const postSuccessMessage = ref(null)
|
||||||
|
const postSuccessStats = ref(null)
|
||||||
|
const showSubscribersModalForGroup = ref(null)
|
||||||
|
const subscribers = ref([])
|
||||||
|
const isLoadingSubscribers = ref(false)
|
||||||
|
const showAddSubscriberModal = ref(false)
|
||||||
|
const addSubscriberForm = ref({
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
customMessage: ''
|
||||||
|
})
|
||||||
|
const isAddingSubscriber = ref(false)
|
||||||
|
const addSubscriberError = ref('')
|
||||||
|
const addSubscriberSuccess = ref('')
|
||||||
|
|
||||||
|
const canCreateGroup = computed(() => {
|
||||||
|
return authStore.hasAnyRole('admin', 'vorstand', 'newsletter')
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupFormData = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: '',
|
||||||
|
targetGroup: '',
|
||||||
|
sendToExternal: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const postFormData = ref({
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadGroups()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const response = await $fetch('/api/newsletter/groups/list')
|
||||||
|
groups.value = response.groups || []
|
||||||
|
|
||||||
|
// Lade Posts für jede Gruppe
|
||||||
|
for (const group of groups.value) {
|
||||||
|
await loadPostsForGroup(group.id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Newsletter-Gruppen:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPostsForGroup(groupId) {
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`/api/newsletter/groups/${groupId}/posts/list`)
|
||||||
|
groupPosts.value[groupId] = response.posts || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Fehler beim Laden der Posts für Gruppe ${groupId}:`, error)
|
||||||
|
groupPosts.value[groupId] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return ''
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTargetGroup(group) {
|
||||||
|
const groups = {
|
||||||
|
alle: 'Alle',
|
||||||
|
erwachsene: 'Erwachsene',
|
||||||
|
nachwuchs: 'Nachwuchs',
|
||||||
|
mannschaftsspieler: 'Mannschaftsspieler',
|
||||||
|
vorstand: 'Vorstand'
|
||||||
|
}
|
||||||
|
return groups[group] || group
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroupPosts(groupId) {
|
||||||
|
expandedGroups.value[groupId] = !expandedGroups.value[groupId]
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePostRecipients(postId) {
|
||||||
|
expandedPosts.value[postId] = !expandedPosts.value[postId]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGroupTypeChange() {
|
||||||
|
if (groupFormData.value.type === 'subscription') {
|
||||||
|
groupFormData.value.targetGroup = ''
|
||||||
|
} else if (groupFormData.value.type === 'group') {
|
||||||
|
groupFormData.value.sendToExternal = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGroupModal() {
|
||||||
|
showCreateGroupModal.value = false
|
||||||
|
groupFormData.value = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: '',
|
||||||
|
targetGroup: '',
|
||||||
|
sendToExternal: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroup() {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/newsletter/groups/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: groupFormData.value
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadGroups()
|
||||||
|
closeGroupModal()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen der Newsletter-Gruppe:', error)
|
||||||
|
alert(error.data?.statusMessage || 'Fehler beim Erstellen der Newsletter-Gruppe')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPostModal(group) {
|
||||||
|
showPostModalForGroup.value = group
|
||||||
|
postFormData.value = {
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
postSuccessMessage.value = null
|
||||||
|
postSuccessStats.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePostModal() {
|
||||||
|
showPostModalForGroup.value = null
|
||||||
|
postFormData.value = {
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
postSuccessMessage.value = null
|
||||||
|
postSuccessStats.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSubscribersModal(group) {
|
||||||
|
showSubscribersModalForGroup.value = group
|
||||||
|
await loadSubscribers(group.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSubscribersModal() {
|
||||||
|
showSubscribersModalForGroup.value = null
|
||||||
|
subscribers.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubscribers(groupId) {
|
||||||
|
try {
|
||||||
|
isLoadingSubscribers.value = true
|
||||||
|
const response = await $fetch(`/api/newsletter/groups/${groupId}/subscribers/list`)
|
||||||
|
subscribers.value = response.subscribers || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Abonnenten:', error)
|
||||||
|
alert(error.data?.statusMessage || 'Fehler beim Laden der Abonnenten')
|
||||||
|
subscribers.value = []
|
||||||
|
} finally {
|
||||||
|
isLoadingSubscribers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSubscriber(subscriberId) {
|
||||||
|
if (!confirm('Möchten Sie diesen Abonnenten wirklich entfernen?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/newsletter/groups/${showSubscribersModalForGroup.value.id}/subscribers/remove`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { subscriberId }
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadSubscribers(showSubscribersModalForGroup.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Entfernen des Abonnenten:', error)
|
||||||
|
alert(error.data?.statusMessage || 'Fehler beim Entfernen des Abonnenten')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddSubscriberModal() {
|
||||||
|
showAddSubscriberModal.value = false
|
||||||
|
addSubscriberForm.value = {
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
customMessage: ''
|
||||||
|
}
|
||||||
|
addSubscriberError.value = ''
|
||||||
|
addSubscriberSuccess.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSubscriber() {
|
||||||
|
if (!showSubscribersModalForGroup.value) return
|
||||||
|
|
||||||
|
isAddingSubscriber.value = true
|
||||||
|
addSubscriberError.value = ''
|
||||||
|
addSubscriberSuccess.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`/api/newsletter/groups/${showSubscribersModalForGroup.value.id}/subscribers/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: addSubscriberForm.value
|
||||||
|
})
|
||||||
|
|
||||||
|
addSubscriberSuccess.value = response.message || 'Empfänger erfolgreich hinzugefügt'
|
||||||
|
|
||||||
|
// Nach 2 Sekunden schließen und Liste aktualisieren
|
||||||
|
setTimeout(async () => {
|
||||||
|
await loadSubscribers(showSubscribersModalForGroup.value.id)
|
||||||
|
closeAddSubscriberModal()
|
||||||
|
}, 2000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Hinzufügen des Empfängers:', error)
|
||||||
|
addSubscriberError.value = error.data?.statusMessage || error.message || 'Fehler beim Hinzufügen des Empfängers'
|
||||||
|
} finally {
|
||||||
|
isAddingSubscriber.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePost() {
|
||||||
|
if (!showPostModalForGroup.value) return
|
||||||
|
|
||||||
|
if (!postFormData.value.title || !postFormData.value.content ||
|
||||||
|
!postFormData.value.content.trim() || postFormData.value.content === '<p><br></p>') {
|
||||||
|
alert('Bitte geben Sie einen Titel und Inhalt ein.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSendingPost.value = true
|
||||||
|
|
||||||
|
const response = await $fetch(`/api/newsletter/groups/${showPostModalForGroup.value.id}/posts/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
title: postFormData.value.title,
|
||||||
|
content: postFormData.value.content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
postSuccessMessage.value = 'Post erfolgreich erstellt und versendet!'
|
||||||
|
postSuccessStats.value = response.stats
|
||||||
|
|
||||||
|
await loadPostsForGroup(showPostModalForGroup.value.id)
|
||||||
|
await loadGroups() // Aktualisiere Post-Count
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen des Posts:', error)
|
||||||
|
alert(error.data?.statusMessage || 'Fehler beim Erstellen des Posts')
|
||||||
|
} finally {
|
||||||
|
isSendingPost.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -325,16 +325,6 @@ const processFile = async (file) => {
|
|||||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
||||||
columnsSelected.value = false
|
columnsSelected.value = false
|
||||||
|
|
||||||
// Debug: Zeige verfügbare Spalten
|
|
||||||
console.log('Verfügbare Spalten:', csvHeaders.value)
|
|
||||||
const halleSpalten = csvHeaders.value.filter(header =>
|
|
||||||
header.toLowerCase().includes('halle') ||
|
|
||||||
header.toLowerCase().includes('strasse') ||
|
|
||||||
header.toLowerCase().includes('plz') ||
|
|
||||||
header.toLowerCase().includes('ort')
|
|
||||||
)
|
|
||||||
console.log('Halle-Spalten gefunden:', halleSpalten)
|
|
||||||
|
|
||||||
// Datei-Info speichern
|
// Datei-Info speichern
|
||||||
currentFile.value = {
|
currentFile.value = {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
@@ -550,7 +540,6 @@ const loadExistingData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Keine bestehende Spielplan-Datei gefunden')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -123,9 +123,28 @@
|
|||||||
<span class="w-8 h-8 bg-primary-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
<span class="w-8 h-8 bg-primary-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
||||||
{{ result.platz }}
|
{{ result.platz }}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
|
<div v-if="result.imageFilename1" class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${result.imageFilename1}?width=32&height=32`"
|
||||||
|
:alt="result.spieler1"
|
||||||
|
class="w-8 h-8 rounded-full object-cover border border-gray-300"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span class="font-medium text-gray-900">{{ result.spieler1 }}</span>
|
<span class="font-medium text-gray-900">{{ result.spieler1 }}</span>
|
||||||
<span v-if="result.spieler2" class="text-gray-600"> & {{ result.spieler2 }}</span>
|
<span v-if="result.spieler2" class="text-gray-600 flex items-center gap-2">
|
||||||
|
&
|
||||||
|
<div v-if="result.imageFilename2" class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${result.imageFilename2}?width=32&height=32`"
|
||||||
|
:alt="result.spieler2"
|
||||||
|
class="w-8 h-8 rounded-full object-cover border border-gray-300"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{ result.spieler2 }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -170,12 +189,15 @@
|
|||||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
@click.self="closeModal"
|
@click.self="closeModal"
|
||||||
>
|
>
|
||||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
<div class="bg-white rounded-lg max-w-md w-full max-h-[90vh] flex flex-col">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
{{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
</h3>
|
{{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="saveResult" class="space-y-4">
|
<div class="overflow-y-auto flex-1 p-6">
|
||||||
|
<form id="result-form" @submit.prevent="saveResult" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label>
|
||||||
<input
|
<input
|
||||||
@@ -236,6 +258,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ImageUpload
|
||||||
|
v-model="formData.imageFilename1"
|
||||||
|
label="Foto Spieler 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formData.kategorie === 'Doppel' || formData.kategorie === 'Mixed'">
|
||||||
|
<ImageUpload
|
||||||
|
v-model="formData.imageFilename2"
|
||||||
|
label="Foto Spieler 2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung (optional)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -245,22 +281,25 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
</form>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
@click="closeModal"
|
<div class="p-6 border-t border-gray-200 flex-shrink-0 flex justify-end space-x-3">
|
||||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
<button
|
||||||
>
|
type="button"
|
||||||
Abbrechen
|
@click="closeModal"
|
||||||
</button>
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
<button
|
>
|
||||||
type="submit"
|
Abbrechen
|
||||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
</button>
|
||||||
>
|
<button
|
||||||
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
|
type="submit"
|
||||||
</button>
|
form="result-form"
|
||||||
</div>
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
</form>
|
>
|
||||||
|
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,7 +369,9 @@ const formData = ref({
|
|||||||
platz: '',
|
platz: '',
|
||||||
spieler1: '',
|
spieler1: '',
|
||||||
spieler2: '',
|
spieler2: '',
|
||||||
bemerkung: ''
|
bemerkung: '',
|
||||||
|
imageFilename1: '',
|
||||||
|
imageFilename2: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadResults = async () => {
|
const loadResults = async () => {
|
||||||
@@ -370,6 +411,7 @@ const loadResults = async () => {
|
|||||||
}
|
}
|
||||||
values.push(current.trim())
|
values.push(current.trim())
|
||||||
|
|
||||||
|
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
|
||||||
if (values.length < 6) return null
|
if (values.length < 6) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -378,7 +420,9 @@ const loadResults = async () => {
|
|||||||
platz: values[2].trim(),
|
platz: values[2].trim(),
|
||||||
spieler1: values[3].trim(),
|
spieler1: values[3].trim(),
|
||||||
spieler2: values[4].trim(),
|
spieler2: values[4].trim(),
|
||||||
bemerkung: values[5].trim()
|
bemerkung: values[5].trim(),
|
||||||
|
imageFilename1: values[6]?.trim() || '',
|
||||||
|
imageFilename2: values[7]?.trim() || ''
|
||||||
}
|
}
|
||||||
}).filter(result => result !== null)
|
}).filter(result => result !== null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -445,7 +489,9 @@ const addNewResult = () => {
|
|||||||
platz: '',
|
platz: '',
|
||||||
spieler1: '',
|
spieler1: '',
|
||||||
spieler2: '',
|
spieler2: '',
|
||||||
bemerkung: ''
|
bemerkung: '',
|
||||||
|
imageFilename1: '',
|
||||||
|
imageFilename2: ''
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -461,7 +507,9 @@ const addResultForYear = (jahr) => {
|
|||||||
platz: '',
|
platz: '',
|
||||||
spieler1: '',
|
spieler1: '',
|
||||||
spieler2: '',
|
spieler2: '',
|
||||||
bemerkung: ''
|
bemerkung: '',
|
||||||
|
imageFilename1: '',
|
||||||
|
imageFilename2: ''
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -477,7 +525,9 @@ const addResultForKategorie = (jahr, kategorie) => {
|
|||||||
platz: '',
|
platz: '',
|
||||||
spieler1: '',
|
spieler1: '',
|
||||||
spieler2: '',
|
spieler2: '',
|
||||||
bemerkung: ''
|
bemerkung: '',
|
||||||
|
imageFilename1: '',
|
||||||
|
imageFilename2: ''
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -493,7 +543,9 @@ const editResult = (result, jahr, kategorie, index) => {
|
|||||||
platz: result.platz,
|
platz: result.platz,
|
||||||
spieler1: result.spieler1,
|
spieler1: result.spieler1,
|
||||||
spieler2: result.spieler2,
|
spieler2: result.spieler2,
|
||||||
bemerkung: result.bemerkung
|
bemerkung: result.bemerkung,
|
||||||
|
imageFilename1: result.imageFilename1 || '',
|
||||||
|
imageFilename2: result.imageFilename2 || ''
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -649,7 +701,7 @@ const closeBemerkungModal = () => {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
// CSV generieren
|
// CSV generieren
|
||||||
const csvHeader = 'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung'
|
const csvHeader = 'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2'
|
||||||
const csvRows = results.value.map(result => {
|
const csvRows = results.value.map(result => {
|
||||||
return [
|
return [
|
||||||
result.jahr,
|
result.jahr,
|
||||||
@@ -657,7 +709,9 @@ const save = async () => {
|
|||||||
result.platz,
|
result.platz,
|
||||||
result.spieler1,
|
result.spieler1,
|
||||||
result.spieler2,
|
result.spieler2,
|
||||||
result.bemerkung
|
result.bemerkung,
|
||||||
|
result.imageFilename1 || '',
|
||||||
|
result.imageFilename2 || ''
|
||||||
].map(field => `"${field}"`).join(',')
|
].map(field => `"${field}"`).join(',')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ const handleLogin = async () => {
|
|||||||
|
|
||||||
// Redirect based on role
|
// Redirect based on role
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (response.user.role === 'admin' || response.user.role === 'vorstand') {
|
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||||
|
if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||||
router.push('/cms')
|
router.push('/cms')
|
||||||
} else {
|
} else {
|
||||||
router.push('/mitgliederbereich')
|
router.push('/mitgliederbereich')
|
||||||
|
|||||||
@@ -337,14 +337,6 @@ const filterData = () => {
|
|||||||
const runde = (row.Runde || '').toLowerCase()
|
const runde = (row.Runde || '').toLowerCase()
|
||||||
const isMatch = runde === 'vr' || runde === 'rr' || runde.includes('vorrunde') || runde.includes('rückrunde')
|
const isMatch = runde === 'vr' || runde === 'rr' || runde.includes('vorrunde') || runde.includes('rückrunde')
|
||||||
|
|
||||||
// Debug: Zeige gefilterte Spiele
|
|
||||||
if (!isMatch && Math.random() < 0.1) { // 10% der gefilterten Spiele loggen
|
|
||||||
console.log('Gefiltert heraus:', row.Termin, 'Runde:', row.Runde)
|
|
||||||
}
|
|
||||||
if (isMatch && Math.random() < 0.05) { // 5% der akzeptierten Spiele loggen
|
|
||||||
console.log('Akzeptiert als Punktrunde:', row.Termin, 'Runde:', row.Runde)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isMatch
|
return isMatch
|
||||||
})
|
})
|
||||||
} else if (selectedWettbewerb.value === 'pokal') {
|
} else if (selectedWettbewerb.value === 'pokal') {
|
||||||
@@ -356,9 +348,6 @@ const filterData = () => {
|
|||||||
}
|
}
|
||||||
// "alle" zeigt alle Spiele ohne weitere Filterung
|
// "alle" zeigt alle Spiele ohne weitere Filterung
|
||||||
|
|
||||||
console.log('selectedWettbewerb.value:', selectedWettbewerb.value)
|
|
||||||
console.log('Nach Wettbewerb-Filter:', wettbewerbFiltered.length, 'von', saisonFiltered.length)
|
|
||||||
|
|
||||||
// Dann nach Mannschaft filtern
|
// Dann nach Mannschaft filtern
|
||||||
if (selectedFilter.value === 'all') {
|
if (selectedFilter.value === 'all') {
|
||||||
filteredData.value = wettbewerbFiltered
|
filteredData.value = wettbewerbFiltered
|
||||||
@@ -495,7 +484,6 @@ const filterData = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Finale gefilterte Daten:', filteredData.value.length, 'von', spielplanData.value.length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadPDF = () => {
|
const downloadPDF = () => {
|
||||||
|
|||||||
@@ -509,7 +509,7 @@ const bulkResponse = await fetch('/api/members/bulk', {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const result = await bulkResponse.json()
|
const result = await bulkResponse.json()
|
||||||
console.log(`Importiert: ${result.summary.imported}, Duplikate: ${result.summary.duplicates}`)</code></pre>
|
// Ergebnis: Importiert: ${result.summary.imported}, Duplikate: ${result.summary.duplicates}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">E-Mail</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">E-Mail</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Telefon</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Telefon</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
<th v-if="canEdit" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
<th v-if="canEdit" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -79,6 +80,32 @@
|
|||||||
</template>
|
</template>
|
||||||
<span v-else class="text-sm text-gray-400">Nur für Vorstand</span>
|
<span v-else class="text-sm text-gray-400">Nur für Vorstand</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
@click="toggleMannschaftsspieler(member)"
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full transition-colors',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
title="Klicken zum Umschalten"
|
||||||
|
>
|
||||||
|
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span
|
<span
|
||||||
@@ -153,6 +180,30 @@
|
|||||||
>
|
>
|
||||||
Aus Login-System
|
Aus Login-System
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
@click="toggleMannschaftsspieler(member)"
|
||||||
|
:class="[
|
||||||
|
'ml-2 px-2 py-1 text-xs font-medium rounded-full transition-colors',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
title="Klicken zum Umschalten"
|
||||||
|
>
|
||||||
|
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'ml-2 px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
|
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
|
||||||
@@ -296,6 +347,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="formData.isMannschaftsspieler"
|
||||||
|
type="checkbox"
|
||||||
|
id="isMannschaftsspieler"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
:disabled="isSaving"
|
||||||
|
/>
|
||||||
|
<label for="isMannschaftsspieler" class="ml-2 block text-sm font-medium text-gray-700">
|
||||||
|
Mannschaftsspieler
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
||||||
<AlertCircle :size="20" class="mr-2" />
|
<AlertCircle :size="20" class="mr-2" />
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
@@ -494,18 +558,17 @@ const formData = ref({
|
|||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
notes: ''
|
notes: '',
|
||||||
|
isMannschaftsspieler: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => {
|
||||||
return authStore.role === 'admin' || authStore.role === 'vorstand'
|
return authStore.hasAnyRole('admin', 'vorstand')
|
||||||
})
|
})
|
||||||
|
|
||||||
const canViewContactData = computed(() => {
|
const canViewContactData = computed(() => {
|
||||||
// Explicitly check for 'vorstand' role only
|
// Explicitly check for 'vorstand' role only
|
||||||
const role = authStore.role
|
return authStore.hasRole('vorstand')
|
||||||
console.log('Current role:', role, 'Can view contact:', role === 'vorstand')
|
|
||||||
return role === 'vorstand'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
@@ -529,7 +592,8 @@ const openAddModal = () => {
|
|||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
notes: ''
|
notes: '',
|
||||||
|
isMannschaftsspieler: false
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -544,7 +608,8 @@ const openEditModal = (member) => {
|
|||||||
email: member.email || '',
|
email: member.email || '',
|
||||||
phone: member.phone || '',
|
phone: member.phone || '',
|
||||||
address: member.address || '',
|
address: member.address || '',
|
||||||
notes: member.notes || ''
|
notes: member.notes || '',
|
||||||
|
isMannschaftsspieler: member.isMannschaftsspieler === true
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -588,6 +653,27 @@ const saveMember = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMannschaftsspieler = async (member) => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/members/toggle-mannschaftsspieler', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { memberId: member.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update local state immediately
|
||||||
|
member.isMannschaftsspieler = response.isMannschaftsspieler
|
||||||
|
|
||||||
|
// Reload to ensure consistency
|
||||||
|
await loadMembers()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Umschalten des Mannschaftsspieler-Status:', error)
|
||||||
|
const errorMsg = error.data?.message || error.message || 'Fehler beim Umschalten des Status.'
|
||||||
|
if (window.showErrorModal) {
|
||||||
|
window.showErrorModal('Fehler', errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const confirmDelete = async (member) => {
|
const confirmDelete = async (member) => {
|
||||||
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
|
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ const formData = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canWrite = computed(() => {
|
const canWrite = computed(() => {
|
||||||
return authStore.role === 'admin' || authStore.role === 'vorstand'
|
return authStore.hasAnyRole('admin', 'vorstand')
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadNews = async () => {
|
const loadNews = async () => {
|
||||||
|
|||||||
75
pages/newsletter/confirm.vue
Normal file
75
pages/newsletter/confirm.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<div v-if="loading" class="py-12">
|
||||||
|
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-8 h-8 text-blue-600 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg text-gray-600">Newsletter-Anmeldung wird bestätigt...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="py-12">
|
||||||
|
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900 mb-4">
|
||||||
|
Fehler
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-600 mb-8">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/newsletter/subscribe"
|
||||||
|
class="inline-block px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Newsletter bestätigen - Harheimer TC',
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const token = route.query.token
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
error.value = 'Bestätigungstoken fehlt'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Rufe den API-Endpoint auf, der die Bestätigung durchführt
|
||||||
|
const response = await $fetch(`/api/newsletter/confirm?token=${token}`)
|
||||||
|
|
||||||
|
// Wenn erfolgreich, weiterleiten zur Bestätigungsseite
|
||||||
|
if (response.alreadyConfirmed) {
|
||||||
|
await navigateTo('/newsletter/confirmed?already=true')
|
||||||
|
} else {
|
||||||
|
await navigateTo('/newsletter/confirmed')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler bei Newsletter-Bestätigung:', err)
|
||||||
|
error.value = err.data?.statusMessage || err.message || 'Fehler bei der Newsletter-Bestätigung'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
40
pages/newsletter/confirmed.vue
Normal file
40
pages/newsletter/confirmed.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900 mb-4">
|
||||||
|
{{ alreadyConfirmed ? 'Bereits bestätigt' : 'Anmeldung bestätigt!' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-lg text-gray-600 mb-8">
|
||||||
|
{{ alreadyConfirmed
|
||||||
|
? 'Ihre Newsletter-Anmeldung wurde bereits bestätigt.'
|
||||||
|
: 'Vielen Dank! Ihre Newsletter-Anmeldung wurde erfolgreich bestätigt. Sie erhalten ab sofort unseren Newsletter.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="inline-block px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zur Startseite
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
const alreadyConfirmed = route.query.already === 'true'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Newsletter bestätigt - Harheimer TC',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
179
pages/newsletter/subscribe.vue
Normal file
179
pages/newsletter/subscribe.vue
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8">
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900 mb-6">
|
||||||
|
Newsletter abonnieren
|
||||||
|
</h1>
|
||||||
|
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||||
|
|
||||||
|
<div v-if="loadingGroups" class="text-center py-8">
|
||||||
|
<p class="text-gray-600">Lade verfügbare Newsletter...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="subscribe" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="groupId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Newsletter auswählen *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="groupId"
|
||||||
|
v-model="form.groupId"
|
||||||
|
required
|
||||||
|
@change="checkSubscription"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen Sie einen Newsletter</option>
|
||||||
|
<option v-for="group in groups" :key="group.id" :value="group.id">
|
||||||
|
{{ group.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="selectedGroup?.description" class="mt-2 text-sm text-gray-600">
|
||||||
|
{{ selectedGroup.description }}
|
||||||
|
</p>
|
||||||
|
<div v-if="alreadySubscribed" class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p class="text-sm text-blue-700">
|
||||||
|
✓ Sie sind bereits für diesen Newsletter angemeldet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
E-Mail-Adresse *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
@blur="checkSubscription"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="ihre.email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Name (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Ihr Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="p-4 bg-green-50 border border-green-200 rounded-lg text-green-700">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || alreadySubscribed || !form.groupId"
|
||||||
|
class="w-full px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Wird verarbeitet...' : alreadySubscribed ? 'Bereits abonniert' : 'Newsletter abonnieren' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Newsletter abonnieren - Harheimer TC',
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = ref([])
|
||||||
|
const loadingGroups = ref(true)
|
||||||
|
const form = ref({
|
||||||
|
groupId: '',
|
||||||
|
email: '',
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const checking = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const alreadySubscribed = ref(false)
|
||||||
|
|
||||||
|
const selectedGroup = computed(() => {
|
||||||
|
return groups.value.find(g => g.id === form.value.groupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/newsletter/groups/public-list')
|
||||||
|
groups.value = response.groups || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Newsletter-Gruppen:', err)
|
||||||
|
error.value = 'Fehler beim Laden der verfügbaren Newsletter. Bitte versuchen Sie es später erneut.'
|
||||||
|
} finally {
|
||||||
|
loadingGroups.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSubscription() {
|
||||||
|
if (!form.value.groupId || !form.value.email || !form.value.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||||
|
alreadySubscribed.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checking.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/newsletter/check-subscription', {
|
||||||
|
query: {
|
||||||
|
email: form.value.email,
|
||||||
|
groupId: form.value.groupId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
alreadySubscribed.value = response.subscribed || false
|
||||||
|
} catch (err) {
|
||||||
|
// Fehler ignorieren - könnte bedeuten, dass nicht abonniert ist
|
||||||
|
alreadySubscribed.value = false
|
||||||
|
} finally {
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
if (alreadySubscribed.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/newsletter/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form.value
|
||||||
|
})
|
||||||
|
|
||||||
|
success.value = response.message || 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet.'
|
||||||
|
form.value = { groupId: form.value.groupId, email: '', name: '' }
|
||||||
|
alreadySubscribed.value = false
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.data?.statusMessage || err.message || 'Fehler bei der Anmeldung. Bitte versuchen Sie es später erneut.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadGroups()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
128
pages/newsletter/unsubscribe.vue
Normal file
128
pages/newsletter/unsubscribe.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8">
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900 mb-6">
|
||||||
|
Newsletter abmelden
|
||||||
|
</h1>
|
||||||
|
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||||
|
|
||||||
|
<div v-if="loadingGroups" class="text-center py-8">
|
||||||
|
<p class="text-gray-600">Lade verfügbare Newsletter...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="unsubscribe" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="groupId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Newsletter auswählen *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="groupId"
|
||||||
|
v-model="form.groupId"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen Sie einen Newsletter</option>
|
||||||
|
<option v-for="group in groups" :key="group.id" :value="group.id">
|
||||||
|
{{ group.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="selectedGroup?.description" class="mt-2 text-sm text-gray-600">
|
||||||
|
{{ selectedGroup.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
E-Mail-Adresse *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="ihre.email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="p-4 bg-green-50 border border-green-200 rounded-lg text-green-700">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || !form.groupId"
|
||||||
|
class="w-full px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Wird verarbeitet...' : 'Newsletter abmelden' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Newsletter abmelden - Harheimer TC',
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = ref([])
|
||||||
|
const loadingGroups = ref(true)
|
||||||
|
const form = ref({
|
||||||
|
groupId: '',
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
const selectedGroup = computed(() => {
|
||||||
|
return groups.value.find(g => g.id === form.value.groupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/newsletter/groups/public-list')
|
||||||
|
groups.value = response.groups || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Newsletter-Gruppen:', err)
|
||||||
|
error.value = 'Fehler beim Laden der verfügbaren Newsletter. Bitte versuchen Sie es später erneut.'
|
||||||
|
} finally {
|
||||||
|
loadingGroups.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribe() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/newsletter/unsubscribe-by-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form.value
|
||||||
|
})
|
||||||
|
|
||||||
|
success.value = response.message || 'Sie wurden erfolgreich vom Newsletter abgemeldet.'
|
||||||
|
form.value = { groupId: '', email: '' }
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.data?.statusMessage || err.message || 'Fehler bei der Abmeldung. Bitte versuchen Sie es später erneut.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadGroups()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
40
pages/newsletter/unsubscribed.vue
Normal file
40
pages/newsletter/unsubscribed.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900 mb-4">
|
||||||
|
{{ alreadyUnsubscribed ? 'Bereits abgemeldet' : 'Erfolgreich abgemeldet' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-lg text-gray-600 mb-8">
|
||||||
|
{{ alreadyUnsubscribed
|
||||||
|
? 'Sie sind bereits vom Newsletter abgemeldet.'
|
||||||
|
: 'Sie wurden erfolgreich vom Newsletter abgemeldet. Sie erhalten keine weiteren Newsletter mehr.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="inline-block px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zur Startseite
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
const alreadyUnsubscribed = route.query.already === 'true'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Newsletter abgemeldet - Harheimer TC',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -44,13 +44,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">{{ gruppe }}</h3>
|
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">{{ gruppe }}</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p
|
<div
|
||||||
v-for="zeit in zeiten"
|
v-for="zeit in zeiten"
|
||||||
:key="zeit.id"
|
:key="zeit.id"
|
||||||
class="text-lg font-semibold text-primary-600"
|
|
||||||
>
|
>
|
||||||
{{ zeit.tag }}: {{ zeit.von }} - {{ zeit.bis }} Uhr
|
<p class="text-lg font-semibold text-primary-600">
|
||||||
</p>
|
{{ zeit.tag }}: {{ zeit.von }} - {{ zeit.bis }} Uhr
|
||||||
|
</p>
|
||||||
|
<p v-if="zeit.info" class="text-sm text-gray-600 mt-1 italic">
|
||||||
|
{{ zeit.info }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Clock :size="32" class="text-primary-600" />
|
<Clock :size="32" class="text-primary-600" />
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
:key="trainer.id"
|
:key="trainer.id"
|
||||||
class="bg-white p-8 rounded-xl shadow-lg"
|
class="bg-white p-8 rounded-xl shadow-lg"
|
||||||
>
|
>
|
||||||
|
<div v-if="trainer.imageFilename" class="mb-4 flex justify-center">
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${trainer.imageFilename}?width=200&height=200`"
|
||||||
|
:alt="trainer.name"
|
||||||
|
class="w-32 h-32 object-cover rounded-full border-4 border-primary-100 shadow-md"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">{{ trainer.lizenz }}</h3>
|
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">{{ trainer.lizenz }}</h3>
|
||||||
<p class="text-gray-600 mb-4">{{ trainer.name }}</p>
|
<p class="text-gray-600 mb-4">{{ trainer.name }}</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -250,7 +250,10 @@ const uploadForm = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isAdmin = computed(() => authStore.isAdmin)
|
const isAdmin = computed(() => authStore.isAdmin)
|
||||||
const isVorstand = computed(() => authStore.user?.role === 'vorstand')
|
const isVorstand = computed(() => {
|
||||||
|
const roles = authStore.user?.roles || (authStore.user?.role ? [authStore.user.role] : [])
|
||||||
|
return roles.includes('vorstand')
|
||||||
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Galerie - Harheimer TC',
|
title: 'Galerie - Harheimer TC',
|
||||||
|
|||||||
@@ -88,13 +88,37 @@
|
|||||||
>
|
>
|
||||||
{{ ergebnis.platz }}
|
{{ ergebnis.platz }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold text-gray-900">
|
<div v-if="ergebnis.imageFilename1" class="flex-shrink-0">
|
||||||
{{ ergebnis.spieler1 }}
|
<img
|
||||||
<span v-if="ergebnis.spieler2" class="text-gray-600">
|
:src="`/api/personen/${ergebnis.imageFilename1}?width=40&height=40`"
|
||||||
/ {{ ergebnis.spieler2 }}
|
:alt="ergebnis.spieler1"
|
||||||
|
class="w-10 h-10 rounded-full object-cover border-2 border-gray-300 cursor-pointer hover:border-primary-500 transition-colors"
|
||||||
|
loading="lazy"
|
||||||
|
@click="openLightbox(ergebnis.imageFilename1, ergebnis.spieler1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ ergebnis.spieler1 }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span v-if="ergebnis.spieler2" class="text-gray-600">
|
||||||
|
<span v-if="ergebnis.imageFilename2" class="ml-2 inline-flex items-center gap-2">
|
||||||
|
/
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${ergebnis.imageFilename2}?width=40&height=40`"
|
||||||
|
:alt="ergebnis.spieler2"
|
||||||
|
class="w-10 h-10 rounded-full object-cover border-2 border-gray-300 cursor-pointer hover:border-primary-500 transition-colors"
|
||||||
|
loading="lazy"
|
||||||
|
@click="openLightbox(ergebnis.imageFilename2, ergebnis.spieler2)"
|
||||||
|
/>
|
||||||
|
{{ ergebnis.spieler2 }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-600">
|
||||||
|
/ {{ ergebnis.spieler2 }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
@@ -147,6 +171,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox für Bilder -->
|
||||||
|
<div
|
||||||
|
v-if="lightboxImage"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
|
||||||
|
@click="closeLightbox"
|
||||||
|
tabindex="0"
|
||||||
|
@keydown="handleLightboxKeydown"
|
||||||
|
>
|
||||||
|
<div class="relative max-w-5xl max-h-full" @click.stop>
|
||||||
|
<!-- Close Button -->
|
||||||
|
<button
|
||||||
|
@click="closeLightbox"
|
||||||
|
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="`/api/personen/${lightboxImage.filename}`"
|
||||||
|
:alt="lightboxImage.name"
|
||||||
|
class="max-w-[90%] max-h-[90vh] object-contain mx-auto"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 text-white text-center">
|
||||||
|
<h3 class="text-xl font-semibold">{{ lightboxImage.name }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -156,6 +211,7 @@ import { Trophy } from 'lucide-vue-next'
|
|||||||
|
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const selectedYear = ref('alle')
|
const selectedYear = ref('alle')
|
||||||
|
const lightboxImage = ref(null)
|
||||||
|
|
||||||
const loadResults = async () => {
|
const loadResults = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -188,6 +244,7 @@ const loadResults = async () => {
|
|||||||
}
|
}
|
||||||
values.push(current.trim())
|
values.push(current.trim())
|
||||||
|
|
||||||
|
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
|
||||||
if (values.length < 6) return null
|
if (values.length < 6) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -196,7 +253,9 @@ const loadResults = async () => {
|
|||||||
platz: values[2].trim(),
|
platz: values[2].trim(),
|
||||||
spieler1: values[3].trim(),
|
spieler1: values[3].trim(),
|
||||||
spieler2: values[4].trim(),
|
spieler2: values[4].trim(),
|
||||||
bemerkung: values[5].trim()
|
bemerkung: values[5].trim(),
|
||||||
|
imageFilename1: values[6]?.trim() || '',
|
||||||
|
imageFilename2: values[7]?.trim() || ''
|
||||||
}
|
}
|
||||||
}).filter(result => result !== null)
|
}).filter(result => result !== null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -268,6 +327,26 @@ const totalDoubles = computed(() => {
|
|||||||
return results.value.filter(r => r.kategorie === 'Doppel' && r.platz === '1').length
|
return results.value.filter(r => r.kategorie === 'Doppel' && r.platz === '1').length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function openLightbox(filename, name) {
|
||||||
|
lightboxImage.value = { filename, name }
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
setTimeout(() => {
|
||||||
|
const modal = document.querySelector('[tabindex="0"]')
|
||||||
|
if (modal) modal.focus()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
lightboxImage.value = null
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLightboxKeydown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeLightbox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadResults()
|
loadResults()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,112 +13,106 @@
|
|||||||
|
|
||||||
<div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose">
|
<div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose">
|
||||||
<!-- Vorsitzender -->
|
<!-- Vorsitzender -->
|
||||||
<div v-if="config.vorstand.vorsitzender.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
<PersonCard
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Vorsitzender</h3>
|
v-if="config.vorstand.vorsitzender.vorname"
|
||||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
title="Vorsitzender"
|
||||||
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}
|
:name="`${config.vorstand.vorsitzender.vorname} ${config.vorstand.vorsitzender.nachname}`"
|
||||||
</h4>
|
:image-filename="config.vorstand.vorsitzender.imageFilename"
|
||||||
<div class="space-y-1 text-gray-600">
|
>
|
||||||
<p v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}</p>
|
<p v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}</p>
|
||||||
<p v-if="config.vorstand.vorsitzender.plz">{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}</p>
|
<p v-if="config.vorstand.vorsitzender.plz">{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}</p>
|
||||||
<p v-if="config.vorstand.vorsitzender.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
|
<p v-if="config.vorstand.vorsitzender.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
|
||||||
<p v-if="config.vorstand.vorsitzender.email">
|
<p v-if="config.vorstand.vorsitzender.email">
|
||||||
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
|
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
|
||||||
{{ config.vorstand.vorsitzender.email }}
|
{{ config.vorstand.vorsitzender.email }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PersonCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stellvertreter -->
|
<!-- Stellvertreter -->
|
||||||
<div v-if="config.vorstand.stellvertreter.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
<PersonCard
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Stellvertreter</h3>
|
v-if="config.vorstand.stellvertreter.vorname"
|
||||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
title="Stellvertreter"
|
||||||
{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }}
|
:name="`${config.vorstand.stellvertreter.vorname} ${config.vorstand.stellvertreter.nachname}`"
|
||||||
</h4>
|
:image-filename="config.vorstand.stellvertreter.imageFilename"
|
||||||
<div class="space-y-1 text-gray-600">
|
>
|
||||||
<p v-if="config.vorstand.stellvertreter.strasse">{{ config.vorstand.stellvertreter.strasse }}</p>
|
<p v-if="config.vorstand.stellvertreter.strasse">{{ config.vorstand.stellvertreter.strasse }}</p>
|
||||||
<p v-if="config.vorstand.stellvertreter.plz">{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}</p>
|
<p v-if="config.vorstand.stellvertreter.plz">{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}</p>
|
||||||
<p v-if="config.vorstand.stellvertreter.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
|
<p v-if="config.vorstand.stellvertreter.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
|
||||||
<p v-if="config.vorstand.stellvertreter.email">
|
<p v-if="config.vorstand.stellvertreter.email">
|
||||||
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
|
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
|
||||||
{{ config.vorstand.stellvertreter.email }}
|
{{ config.vorstand.stellvertreter.email }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PersonCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Kassenwart -->
|
<!-- Kassenwart -->
|
||||||
<div v-if="config.vorstand.kassenwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
<PersonCard
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Kassenwart</h3>
|
v-if="config.vorstand.kassenwart.vorname"
|
||||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
title="Kassenwart"
|
||||||
{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }}
|
:name="`${config.vorstand.kassenwart.vorname} ${config.vorstand.kassenwart.nachname}`"
|
||||||
</h4>
|
:image-filename="config.vorstand.kassenwart.imageFilename"
|
||||||
<div class="space-y-1 text-gray-600">
|
>
|
||||||
<p v-if="config.vorstand.kassenwart.strasse">{{ config.vorstand.kassenwart.strasse }}</p>
|
<p v-if="config.vorstand.kassenwart.strasse">{{ config.vorstand.kassenwart.strasse }}</p>
|
||||||
<p v-if="config.vorstand.kassenwart.plz">{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}</p>
|
<p v-if="config.vorstand.kassenwart.plz">{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}</p>
|
||||||
<p v-if="config.vorstand.kassenwart.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
|
<p v-if="config.vorstand.kassenwart.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
|
||||||
<p v-if="config.vorstand.kassenwart.email">
|
<p v-if="config.vorstand.kassenwart.email">
|
||||||
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
|
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
|
||||||
{{ config.vorstand.kassenwart.email }}
|
{{ config.vorstand.kassenwart.email }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PersonCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schriftführer -->
|
<!-- Schriftführer -->
|
||||||
<div v-if="config.vorstand.schriftfuehrer.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
<PersonCard
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Schriftführer</h3>
|
v-if="config.vorstand.schriftfuehrer.vorname"
|
||||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
title="Schriftführer"
|
||||||
{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }}
|
:name="`${config.vorstand.schriftfuehrer.vorname} ${config.vorstand.schriftfuehrer.nachname}`"
|
||||||
</h4>
|
:image-filename="config.vorstand.schriftfuehrer.imageFilename"
|
||||||
<div class="space-y-1 text-gray-600">
|
>
|
||||||
<p v-if="config.vorstand.schriftfuehrer.strasse">{{ config.vorstand.schriftfuehrer.strasse }}</p>
|
<p v-if="config.vorstand.schriftfuehrer.strasse">{{ config.vorstand.schriftfuehrer.strasse }}</p>
|
||||||
<p v-if="config.vorstand.schriftfuehrer.plz">{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}</p>
|
<p v-if="config.vorstand.schriftfuehrer.plz">{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}</p>
|
||||||
<p v-if="config.vorstand.schriftfuehrer.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
|
<p v-if="config.vorstand.schriftfuehrer.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
|
||||||
<p v-if="config.vorstand.schriftfuehrer.email">
|
<p v-if="config.vorstand.schriftfuehrer.email">
|
||||||
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
|
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
|
||||||
{{ config.vorstand.schriftfuehrer.email }}
|
{{ config.vorstand.schriftfuehrer.email }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PersonCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sportwart -->
|
<!-- Sportwart -->
|
||||||
<div v-if="config.vorstand.sportwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
<PersonCard
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Sportwart</h3>
|
v-if="config.vorstand.sportwart.vorname"
|
||||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
title="Sportwart"
|
||||||
{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }}
|
:name="`${config.vorstand.sportwart.vorname} ${config.vorstand.sportwart.nachname}`"
|
||||||
</h4>
|
:image-filename="config.vorstand.sportwart.imageFilename"
|
||||||
<div class="space-y-1 text-gray-600">
|
>
|
||||||
<p v-if="config.vorstand.sportwart.strasse">{{ config.vorstand.sportwart.strasse }}</p>
|
<p v-if="config.vorstand.sportwart.strasse">{{ config.vorstand.sportwart.strasse }}</p>
|
||||||
<p v-if="config.vorstand.sportwart.plz">{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}</p>
|
<p v-if="config.vorstand.sportwart.plz">{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}</p>
|
||||||
<p v-if="config.vorstand.sportwart.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
|
<p v-if="config.vorstand.sportwart.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
|
||||||
<p v-if="config.vorstand.sportwart.email">
|
<p v-if="config.vorstand.sportwart.email">
|
||||||
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
|
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
|
||||||
{{ config.vorstand.sportwart.email }}
|
{{ config.vorstand.sportwart.email }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PersonCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Jugendwart -->
|
<!-- Jugendwart -->
|
||||||
<div v-if="config.vorstand.jugendwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
<PersonCard
|
||||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Jugendwart</h3>
|
v-if="config.vorstand.jugendwart.vorname"
|
||||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
title="Jugendwart"
|
||||||
{{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }}
|
:name="`${config.vorstand.jugendwart.vorname} ${config.vorstand.jugendwart.nachname}`"
|
||||||
</h4>
|
:image-filename="config.vorstand.jugendwart.imageFilename"
|
||||||
<div class="space-y-1 text-gray-600">
|
>
|
||||||
<p v-if="config.vorstand.jugendwart.strasse">{{ config.vorstand.jugendwart.strasse }}</p>
|
<p v-if="config.vorstand.jugendwart.strasse">{{ config.vorstand.jugendwart.strasse }}</p>
|
||||||
<p v-if="config.vorstand.jugendwart.plz">{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}</p>
|
<p v-if="config.vorstand.jugendwart.plz">{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}</p>
|
||||||
<p v-if="config.vorstand.jugendwart.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
|
<p v-if="config.vorstand.jugendwart.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
|
||||||
<p v-if="config.vorstand.jugendwart.email">
|
<p v-if="config.vorstand.jugendwart.email">
|
||||||
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
|
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
|
||||||
{{ config.vorstand.jugendwart.email }}
|
{{ config.vorstand.jugendwart.email }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PersonCard>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
0
production-setup.sh
Normal file → Executable file
0
production-setup.sh
Normal file → Executable file
@@ -1,49 +1,49 @@
|
|||||||
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung
|
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2
|
||||||
"2024","Einzel","1","Michael Koch","",""
|
"2024","Einzel","1","Michael Koch","","","",""
|
||||||
"2024","Einzel","2","Olaf Nüßlein","",""
|
"2024","Einzel","2","Olaf Nüßlein","","","",""
|
||||||
"2024","Einzel","3","Bernd Meyer","",""
|
"2024","Einzel","3","Bernd Meyer","","","",""
|
||||||
"2024","Doppel","1","Sven Baublies","Johannes Binder",""
|
"2024","Doppel","1","Sven Baublies","Johannes Binder","","",""
|
||||||
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann",""
|
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann","","",""
|
||||||
"2024","Doppel","3","Michael Koch","Jacob Waltenberger",""
|
"2024","Doppel","3","Michael Koch","Jacob Waltenberger","","",""
|
||||||
"2023","Einzel","1","André Gilzinger","",""
|
"2023","Einzel","1","André Gilzinger","","","",""
|
||||||
"2023","Einzel","2","Olaf Nüßlein","",""
|
"2023","Einzel","2","Olaf Nüßlein","","","",""
|
||||||
"2023","Einzel","3","Michael Koch","",""
|
"2023","Einzel","3","Michael Koch","","","",""
|
||||||
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder",""
|
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder","","",""
|
||||||
"2023","Doppel","2","Renate Nebel","André Gilzinger",""
|
"2023","Doppel","2","Renate Nebel","André Gilzinger","","",""
|
||||||
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz",""
|
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz","","",""
|
||||||
"2022","Einzel","1","Sven Baublies","",""
|
"2022","Einzel","1","Sven Baublies","","","",""
|
||||||
"2022","Einzel","2","Thomas Steinbrech","",""
|
"2022","Einzel","2","Thomas Steinbrech","","","",""
|
||||||
"2022","Einzel","3","André Gilzinger","",""
|
"2022","Einzel","3","André Gilzinger","","","",""
|
||||||
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt",""
|
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt","","",""
|
||||||
"2022","Doppel","2","Michael Weber","Johannes Binder",""
|
"2022","Doppel","2","Michael Weber","Johannes Binder","","",""
|
||||||
"2022","Doppel","3","Michael Koch","Renate Nebel",""
|
"2022","Doppel","3","Michael Koch","Renate Nebel","","",""
|
||||||
"2021","","","","","coronabedingter Ausfall"
|
"2021","","","","","coronabedingter Ausfall","",""
|
||||||
"2020","","","","","coronabedingter Ausfall"
|
"2020","","","","","coronabedingter Ausfall","",""
|
||||||
"2019","Einzel","1","André Gilzinger","",""
|
"2019","Einzel","1","André Gilzinger","","","",""
|
||||||
"2019","Einzel","2","Thomas Steinbrech","",""
|
"2019","Einzel","2","Thomas Steinbrech","","","",""
|
||||||
"2019","Einzel","3","Jürgen Kratz","",""
|
"2019","Einzel","3","Jürgen Kratz","","","",""
|
||||||
"2019","Doppel","1","André Gilzinger","Volker Marx",""
|
"2019","Doppel","1","André Gilzinger","Volker Marx","","",""
|
||||||
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau",""
|
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau","","",""
|
||||||
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt",""
|
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt","","",""
|
||||||
"2018","Einzel","1","André Gilzinger","",""
|
"2018","Einzel","1","André Gilzinger","","","",""
|
||||||
"2018","Einzel","2","Jürgen Kratz","",""
|
"2018","Einzel","2","Jürgen Kratz","","","",""
|
||||||
"2018","Einzel","3","Sven Baublies","",""
|
"2018","Einzel","3","Sven Baublies","","","",""
|
||||||
"2018","Doppel","1","André Gilzinger","Volker Marx",""
|
"2018","Doppel","1","André Gilzinger","Volker Marx","","",""
|
||||||
"2018","Doppel","2","Sven Baublies","Helge Stefan",""
|
"2018","Doppel","2","Sven Baublies","Helge Stefan","","",""
|
||||||
"2018","Doppel","3","Jürgen Kratz","Renate Nebel",""
|
"2018","Doppel","3","Jürgen Kratz","Renate Nebel","","",""
|
||||||
"2017","Einzel","1","André Gilzinger","",""
|
"2017","Einzel","1","André Gilzinger","","","",""
|
||||||
"2017","Einzel","2","Sven Baublies","",""
|
"2017","Einzel","2","Sven Baublies","","","",""
|
||||||
"2017","Einzel","3","Olaf Nüßlein","",""
|
"2017","Einzel","3","Olaf Nüßlein","","","",""
|
||||||
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan",""
|
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan","","",""
|
||||||
"2017","Doppel","2","André Gilzinger","Renate Nebel",""
|
"2017","Doppel","2","André Gilzinger","Renate Nebel","","",""
|
||||||
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt",""
|
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt","","",""
|
||||||
"2016","Herren-Einzel","1","André Gilzinger","",""
|
"2016","Herren-Einzel","1","André Gilzinger","","","",""
|
||||||
"2016","Herren-Einzel","2","Sven Baublies","",""
|
"2016","Herren-Einzel","2","Sven Baublies","","","",""
|
||||||
"2016","Herren-Einzel","3","Olaf Nüßlein","",""
|
"2016","Herren-Einzel","3","Olaf Nüßlein","","","",""
|
||||||
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","",""
|
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","","","",""
|
||||||
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","",""
|
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","","","",""
|
||||||
"2016","Damen-Einzel","3","Renate Nebel","",""
|
"2016","Damen-Einzel","3","Renate Nebel","","","",""
|
||||||
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt",""
|
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt","","",""
|
||||||
"2016","Doppel","2","André Gilzinger","Bernd Meyer",""
|
"2016","Doppel","2","André Gilzinger","Bernd Meyer","","",""
|
||||||
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi",""
|
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi","","",""
|
||||||
"2025","Doppel","1","a","b",""
|
"2025","Doppel","1","a","b","","7f6c46f8-b93f-4807-b369-b26e0bba2da5.png","4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png"
|
||||||
|
241
public/images/logos/Harheimer TC.svg
Normal file
241
public/images/logos/Harheimer TC.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.8 MiB |
66
scripts/README-set-admin-password.md
Normal file
66
scripts/README-set-admin-password.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Admin-Passwort setzen
|
||||||
|
|
||||||
|
Dieses Script ermöglicht es, das Passwort für den Admin-User (`admin@harheimertc.de`) zu setzen oder zu ändern.
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Mit Passwort als Argument
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/set-admin-password.js "mein-neues-passwort"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interaktiv (Passwort wird abgefragt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/set-admin-password.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script fragt dann nach dem neuen Passwort.
|
||||||
|
|
||||||
|
### Datei neu erstellen (bei Entschlüsselungsfehler)
|
||||||
|
|
||||||
|
Falls die `users.json` Datei nicht entschlüsselt werden kann (z.B. weil der `ENCRYPTION_KEY` nicht übereinstimmt), wird das Script automatisch fragen, ob die Datei neu erstellt werden soll. Sie können auch direkt das `--recreate` Flag verwenden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/set-admin-password.js "mein-neues-passwort" --recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig**: Die alte Datei wird automatisch als Backup gespeichert, bevor eine neue erstellt wird.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- **Findet oder erstellt den Admin-User**: Falls der Admin-User nicht existiert, wird er automatisch erstellt
|
||||||
|
- **Passwort-Hashing**: Das Passwort wird mit bcrypt gehasht (10 Runden)
|
||||||
|
- **Verschlüsselte Speicherung**: Die Benutzerdaten werden verschlüsselt gespeichert
|
||||||
|
- **Validierung**: Prüft, dass das Passwort mindestens 8 Zeichen lang ist
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- `ENCRYPTION_KEY` muss in der `.env` Datei gesetzt sein (für verschlüsselte Speicherung)
|
||||||
|
- Die Datei `server/data/users.json` muss existieren oder wird automatisch erstellt
|
||||||
|
|
||||||
|
## Beispiel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Passwort direkt setzen
|
||||||
|
node scripts/set-admin-password.js "MeinSicheresPasswort123!"
|
||||||
|
|
||||||
|
# Interaktiv
|
||||||
|
node scripts/set-admin-password.js
|
||||||
|
# Eingabeaufforderung: Neues Passwort für admin@harheimertc.de:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Das Passwort wird niemals im Klartext gespeichert
|
||||||
|
- Es wird mit bcrypt gehasht (10 Runden)
|
||||||
|
- Die Benutzerdaten werden verschlüsselt gespeichert
|
||||||
|
- Das Passwort wird nicht in der Kommandozeilen-Historie gespeichert (bei interaktiver Eingabe)
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
Falls die Entschlüsselung der Benutzerdaten fehlschlägt:
|
||||||
|
- Prüfe, ob `ENCRYPTION_KEY` in der `.env` Datei korrekt gesetzt ist
|
||||||
|
- Stelle sicher, dass der Schlüssel mit dem übereinstimmt, der zum Verschlüsseln verwendet wurde
|
||||||
|
|
||||||
0
scripts/fetch-template.sh
Normal file → Executable file
0
scripts/fetch-template.sh
Normal file → Executable file
@@ -82,10 +82,25 @@ function isEncrypted(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prüft ob Daten bereits mit dem neuen Schlüssel verschlüsselt sind
|
||||||
|
async function isEncryptedWithNewKey(encryptedData) {
|
||||||
|
try {
|
||||||
|
await decryptObject(encryptedData, NEW_KEY)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Versucht mit verschiedenen Schlüsseln zu entschlüsseln
|
// Versucht mit verschiedenen Schlüsseln zu entschlüsseln
|
||||||
async function decryptWithFallback(encryptedData, keys) {
|
async function decryptWithFallback(encryptedData, keys) {
|
||||||
const errors = []
|
const errors = []
|
||||||
|
|
||||||
|
// Prüfe zuerst, ob die Daten bereits mit dem neuen Schlüssel verschlüsselt sind
|
||||||
|
if (await isEncryptedWithNewKey(encryptedData)) {
|
||||||
|
throw new Error('ALREADY_ENCRYPTED_WITH_NEW_KEY')
|
||||||
|
}
|
||||||
|
|
||||||
// encryptedData sollte bereits ein String sein (entweder direkt verschlüsselt oder aus einem JSON-Objekt extrahiert)
|
// encryptedData sollte bereits ein String sein (entweder direkt verschlüsselt oder aus einem JSON-Objekt extrahiert)
|
||||||
// Versuche mit jedem Schlüssel zu entschlüsseln
|
// Versuche mit jedem Schlüssel zu entschlüsseln
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -115,6 +130,12 @@ async function reencryptUsers(backupDir, oldKeys) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
|
||||||
|
if (await isEncryptedWithNewKey(data)) {
|
||||||
|
console.log('ℹ️ users.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🔄 Entschlüssele users.json...')
|
console.log('🔄 Entschlüssele users.json...')
|
||||||
const decrypted = await decryptWithFallback(data, oldKeys)
|
const decrypted = await decryptWithFallback(data, oldKeys)
|
||||||
|
|
||||||
@@ -129,6 +150,10 @@ async function reencryptUsers(backupDir, oldKeys) {
|
|||||||
console.log('ℹ️ users.json existiert nicht, überspringe...')
|
console.log('ℹ️ users.json existiert nicht, überspringe...')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (error.message === 'ALREADY_ENCRYPTED_WITH_NEW_KEY') {
|
||||||
|
console.log('ℹ️ users.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
|
||||||
|
return
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +172,12 @@ async function reencryptMembers(backupDir, oldKeys) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
|
||||||
|
if (await isEncryptedWithNewKey(data)) {
|
||||||
|
console.log('ℹ️ members.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🔄 Entschlüssele members.json...')
|
console.log('🔄 Entschlüssele members.json...')
|
||||||
const decrypted = await decryptWithFallback(data, oldKeys)
|
const decrypted = await decryptWithFallback(data, oldKeys)
|
||||||
|
|
||||||
@@ -161,6 +192,10 @@ async function reencryptMembers(backupDir, oldKeys) {
|
|||||||
console.log('ℹ️ members.json existiert nicht, überspringe...')
|
console.log('ℹ️ members.json existiert nicht, überspringe...')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (error.message === 'ALREADY_ENCRYPTED_WITH_NEW_KEY') {
|
||||||
|
console.log('ℹ️ members.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
|
||||||
|
return
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,28 +232,40 @@ async function reencryptMembershipApplications(backupDir, oldKeys) {
|
|||||||
|
|
||||||
// Prüfe ob encryptedData Feld vorhanden ist
|
// Prüfe ob encryptedData Feld vorhanden ist
|
||||||
if (parsed.encryptedData) {
|
if (parsed.encryptedData) {
|
||||||
console.log(`🔄 Entschlüssele ${file}...`)
|
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
|
||||||
// Nur das encryptedData Feld entschlüsseln
|
if (await isEncryptedWithNewKey(parsed.encryptedData)) {
|
||||||
const decrypted = await decryptWithFallback(parsed.encryptedData, oldKeys)
|
console.log(`ℹ️ ${file} ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...`)
|
||||||
|
skipped++
|
||||||
console.log(`🔐 Verschlüssele ${file} mit neuem Schlüssel...`)
|
} else {
|
||||||
const reencrypted = encryptObject(decrypted, NEW_KEY)
|
console.log(`🔄 Entschlüssele ${file}...`)
|
||||||
|
// Nur das encryptedData Feld entschlüsseln
|
||||||
parsed.encryptedData = reencrypted
|
const decrypted = await decryptWithFallback(parsed.encryptedData, oldKeys)
|
||||||
await fs.writeFile(filePath, JSON.stringify(parsed, null, 2), 'utf-8')
|
|
||||||
console.log(`✅ ${file} erfolgreich neu verschlüsselt`)
|
console.log(`🔐 Verschlüssele ${file} mit neuem Schlüssel...`)
|
||||||
processed++
|
const reencrypted = encryptObject(decrypted, NEW_KEY)
|
||||||
|
|
||||||
|
parsed.encryptedData = reencrypted
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(parsed, null, 2), 'utf-8')
|
||||||
|
console.log(`✅ ${file} erfolgreich neu verschlüsselt`)
|
||||||
|
processed++
|
||||||
|
}
|
||||||
} else if (file.endsWith('.data')) {
|
} else if (file.endsWith('.data')) {
|
||||||
// .data Dateien sind direkt verschlüsselt
|
// .data Dateien sind direkt verschlüsselt
|
||||||
console.log(`🔄 Entschlüssele ${file}...`)
|
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
|
||||||
const decrypted = await decryptWithFallback(content, oldKeys)
|
if (await isEncryptedWithNewKey(content)) {
|
||||||
|
console.log(`ℹ️ ${file} ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...`)
|
||||||
console.log(`🔐 Verschlüssele ${file} mit neuem Schlüssel...`)
|
skipped++
|
||||||
const reencrypted = encrypt(JSON.stringify(decrypted), NEW_KEY)
|
} else {
|
||||||
|
console.log(`🔄 Entschlüssele ${file}...`)
|
||||||
await fs.writeFile(filePath, reencrypted, 'utf-8')
|
const decrypted = await decryptWithFallback(content, oldKeys)
|
||||||
console.log(`✅ ${file} erfolgreich neu verschlüsselt`)
|
|
||||||
processed++
|
console.log(`🔐 Verschlüssele ${file} mit neuem Schlüssel...`)
|
||||||
|
const reencrypted = encrypt(JSON.stringify(decrypted), NEW_KEY)
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, reencrypted, 'utf-8')
|
||||||
|
console.log(`✅ ${file} erfolgreich neu verschlüsselt`)
|
||||||
|
processed++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`ℹ️ ${file} enthält keine verschlüsselten Daten, überspringe...`)
|
console.log(`ℹ️ ${file} enthält keine verschlüsselten Daten, überspringe...`)
|
||||||
skipped++
|
skipped++
|
||||||
|
|||||||
300
scripts/set-admin-password.js
Executable file
300
scripts/set-admin-password.js
Executable file
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Setzen des Admin-Passworts
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node scripts/set-admin-password.js <neues-passwort>
|
||||||
|
*
|
||||||
|
* Oder interaktiv:
|
||||||
|
* node scripts/set-admin-password.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { decryptObject, encryptObject } from '../server/utils/encryption.js'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import readline from 'readline'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
// Lade .env Datei
|
||||||
|
dotenv.config({ path: path.join(__dirname, '..', '.env') })
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = 'admin@harheimertc.de'
|
||||||
|
|
||||||
|
// Pfade bestimmen
|
||||||
|
function getDataPath(filename) {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const USERS_FILE = getDataPath('users.json')
|
||||||
|
|
||||||
|
// Get encryption key from environment
|
||||||
|
function getEncryptionKey() {
|
||||||
|
return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft ob Daten verschlüsselt sind
|
||||||
|
function isEncrypted(data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.trim())
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest Benutzer aus Datei
|
||||||
|
async function readUsers() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(USERS_FILE, 'utf-8')
|
||||||
|
const encrypted = isEncrypted(data)
|
||||||
|
|
||||||
|
if (encrypted) {
|
||||||
|
const encryptionKey = getEncryptionKey()
|
||||||
|
try {
|
||||||
|
return decryptObject(data, encryptionKey)
|
||||||
|
} catch (decryptError) {
|
||||||
|
console.error('Fehler beim Entschlüsseln der Benutzerdaten:', decryptError.message)
|
||||||
|
throw new Error('Konnte Benutzerdaten nicht entschlüsseln. Bitte prüfe ENCRYPTION_KEY in .env')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schreibt Benutzer in Datei (immer verschlüsselt)
|
||||||
|
async function writeUsers(users) {
|
||||||
|
try {
|
||||||
|
const encryptionKey = getEncryptionKey()
|
||||||
|
const encryptedData = encryptObject(users, encryptionKey)
|
||||||
|
await fs.writeFile(USERS_FILE, encryptedData, 'utf-8')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash-Passwort generieren
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const salt = await bcrypt.genSalt(10)
|
||||||
|
return await bcrypt.hash(password, salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragt nach Passwort (wenn nicht als Argument übergeben)
|
||||||
|
function askPassword() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
})
|
||||||
|
|
||||||
|
rl.question('Neues Passwort für admin@harheimertc.de: ', (password) => {
|
||||||
|
rl.close()
|
||||||
|
resolve(password)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragt nach Bestätigung
|
||||||
|
function askConfirmation(question) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
})
|
||||||
|
|
||||||
|
rl.question(`${question} (j/n): `, (answer) => {
|
||||||
|
rl.close()
|
||||||
|
resolve(answer.toLowerCase() === 'j' || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'ja' || answer.toLowerCase() === 'yes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstellt ein Backup der users.json
|
||||||
|
async function createBackup() {
|
||||||
|
try {
|
||||||
|
await fs.access(USERS_FILE)
|
||||||
|
const backupDir = path.join(__dirname, '..', 'backups', `users-${Date.now()}`)
|
||||||
|
await fs.mkdir(backupDir, { recursive: true })
|
||||||
|
const backupPath = path.join(backupDir, 'users.json')
|
||||||
|
await fs.copyFile(USERS_FILE, backupPath)
|
||||||
|
console.log(`📦 Backup erstellt: ${backupPath}`)
|
||||||
|
return backupPath
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
console.log('ℹ️ users.json existiert nicht, kein Backup nötig')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstellt eine neue users.json mit Admin-User
|
||||||
|
async function createNewUsersFile(password) {
|
||||||
|
const hashedPassword = await hashPassword(password)
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
email: ADMIN_EMAIL,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: 'Administrator',
|
||||||
|
role: 'admin',
|
||||||
|
active: true,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
lastLogin: null
|
||||||
|
}
|
||||||
|
|
||||||
|
return [adminUser]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hauptfunktion
|
||||||
|
async function main() {
|
||||||
|
console.log('🔐 Admin-Passwort setzen\n')
|
||||||
|
|
||||||
|
// Prüfe ob --recreate Flag gesetzt ist
|
||||||
|
const recreateFlag = process.argv.includes('--recreate') || process.argv.includes('-r')
|
||||||
|
|
||||||
|
// Passwort aus Kommandozeilenargumenten oder interaktiv abfragen
|
||||||
|
// Filtere Flags heraus und nimm das erste verbleibende Argument als Passwort
|
||||||
|
const args = process.argv.slice(2).filter(arg => !arg.startsWith('--') && !arg.startsWith('-'))
|
||||||
|
let newPassword = args.length > 0 ? args[0] : null
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
newPassword = await askPassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword || newPassword.trim().length === 0) {
|
||||||
|
console.error('❌ FEHLER: Passwort darf nicht leer sein!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
console.error('❌ FEHLER: Passwort muss mindestens 8 Zeichen lang sein!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Benutzer laden
|
||||||
|
console.log('📖 Lade Benutzerdaten...')
|
||||||
|
let users
|
||||||
|
let shouldRecreate = recreateFlag
|
||||||
|
|
||||||
|
try {
|
||||||
|
users = await readUsers()
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('entschlüsseln') || error.message.includes('Entschlüsselung')) {
|
||||||
|
console.error('\n⚠️ WARNUNG: Konnte Benutzerdaten nicht entschlüsseln!')
|
||||||
|
console.error(' Dies bedeutet, dass der ENCRYPTION_KEY nicht mit dem verwendeten Schlüssel übereinstimmt.\n')
|
||||||
|
|
||||||
|
if (!shouldRecreate) {
|
||||||
|
const confirmed = await askConfirmation('Möchten Sie die users.json Datei neu erstellen? (Alte Datei wird als Backup gespeichert)')
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log('\n❌ Abgebrochen. Bitte setzen Sie ENCRYPTION_KEY in der .env Datei auf den korrekten Wert.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
shouldRecreate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup erstellen
|
||||||
|
await createBackup()
|
||||||
|
|
||||||
|
// Neue Datei erstellen
|
||||||
|
console.log('\n🆕 Erstelle neue users.json Datei...')
|
||||||
|
users = await createNewUsersFile(newPassword)
|
||||||
|
|
||||||
|
// Direkt speichern
|
||||||
|
console.log('💾 Speichere neue Benutzerdaten...')
|
||||||
|
const success = await writeUsers(users)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('\n✅ Neue users.json Datei erfolgreich erstellt!')
|
||||||
|
console.log(`📧 E-Mail: ${ADMIN_EMAIL}`)
|
||||||
|
console.log(`👤 Rolle: admin`)
|
||||||
|
console.log(`✅ Status: Aktiv`)
|
||||||
|
console.log(`🔐 Passwort: Gesetzt`)
|
||||||
|
} else {
|
||||||
|
console.error('\n❌ FEHLER: Konnte Benutzerdaten nicht speichern!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-User finden oder erstellen
|
||||||
|
let adminUser = users.find(u => u.email.toLowerCase() === ADMIN_EMAIL.toLowerCase())
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
console.log(`ℹ️ Admin-User (${ADMIN_EMAIL}) nicht gefunden, erstelle neuen Benutzer...`)
|
||||||
|
adminUser = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
email: ADMIN_EMAIL,
|
||||||
|
name: 'Administrator',
|
||||||
|
role: 'admin',
|
||||||
|
active: true,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
lastLogin: null
|
||||||
|
}
|
||||||
|
users.push(adminUser)
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Admin-User gefunden: ${adminUser.name || ADMIN_EMAIL}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passwort hashen
|
||||||
|
console.log('🔐 Hashe Passwort...')
|
||||||
|
const hashedPassword = await hashPassword(newPassword)
|
||||||
|
|
||||||
|
// Passwort setzen
|
||||||
|
adminUser.password = hashedPassword
|
||||||
|
adminUser.updated = new Date().toISOString()
|
||||||
|
|
||||||
|
// Benutzer speichern
|
||||||
|
console.log('💾 Speichere Benutzerdaten...')
|
||||||
|
const success = await writeUsers(users)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('\n✅ Passwort erfolgreich gesetzt!')
|
||||||
|
console.log(`📧 E-Mail: ${ADMIN_EMAIL}`)
|
||||||
|
console.log(`👤 Rolle: ${adminUser.role}`)
|
||||||
|
console.log(`✅ Status: ${adminUser.active ? 'Aktiv' : 'Inaktiv'}`)
|
||||||
|
} else {
|
||||||
|
console.error('\n❌ FEHLER: Konnte Benutzerdaten nicht speichern!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ FEHLER:', error.message)
|
||||||
|
console.error(error.stack)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script ausführen
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('Unerwarteter Fehler:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readUsers, writeUsers, verifyPassword, generateToken, createSession } from '../../utils/auth.js'
|
import { readUsers, writeUsers, verifyPassword, generateToken, createSession, migrateUserRoles } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +59,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
maxAge: 60 * 60 * 24 * 7 // 7 days
|
maxAge: 60 * 60 * 24 * 7 // 7 days
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Migriere Rollen falls nötig
|
||||||
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
|
||||||
// Return user data (without password) and token for API usage
|
// Return user data (without password) and token for API usage
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -67,8 +71,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role
|
roles: roles
|
||||||
}
|
},
|
||||||
|
// Rückwärtskompatibilität: erste Rolle als role
|
||||||
|
role: roles[0] || 'mitglied'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login-Fehler:', error)
|
console.error('Login-Fehler:', error)
|
||||||
|
|||||||
@@ -23,15 +23,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : ['mitglied'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role
|
roles: roles
|
||||||
},
|
},
|
||||||
role: user.role
|
roles: roles,
|
||||||
|
// Rückwärtskompatibilität: erste Rolle als role
|
||||||
|
role: roles[0] || 'mitglied'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth-Status-Fehler:', error)
|
console.error('Auth-Status-Fehler:', error)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { exec } from 'child_process'
|
import { exec } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { getUserFromToken } from '../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
|
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Keine Berechtigung'
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken } from '../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
|
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Keine Berechtigung'
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken } from '../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
|
||||||
// Multer-Konfiguration für PDF-Uploads
|
// Multer-Konfiguration für PDF-Uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -57,7 +57,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
|
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Keine Berechtigung'
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token')
|
||||||
const currentUser = await getUserFromToken(token)
|
const currentUser = await getUserFromToken(token)
|
||||||
|
|
||||||
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
|
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Zugriff verweigert'
|
message: 'Zugriff verweigert'
|
||||||
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { userId, role } = body
|
const { userId, roles } = body
|
||||||
|
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
const user = users.find(u => u.id === userId)
|
const user = users.find(u => u.id === userId)
|
||||||
@@ -26,9 +26,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate user and set role
|
// Migriere Benutzer falls nötig
|
||||||
|
migrateUserRoles(user)
|
||||||
|
|
||||||
|
// Activate user and set roles
|
||||||
user.active = true
|
user.active = true
|
||||||
user.role = role || 'mitglied'
|
if (Array.isArray(roles) && roles.length > 0) {
|
||||||
|
user.roles = roles
|
||||||
|
} else {
|
||||||
|
// Fallback: einzelne Rolle als Array
|
||||||
|
user.roles = roles ? [roles] : ['mitglied']
|
||||||
|
}
|
||||||
|
|
||||||
const updatedUsers = users.map(u => u.id === userId ? user : u)
|
const updatedUsers = users.map(u => u.id === userId ? user : u)
|
||||||
await writeUsers(updatedUsers)
|
await writeUsers(updatedUsers)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token')
|
||||||
const currentUser = await getUserFromToken(token)
|
const currentUser = await getUserFromToken(token)
|
||||||
|
|
||||||
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
|
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Zugriff verweigert'
|
message: 'Zugriff verweigert'
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getUserFromToken, readUsers } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token')
|
||||||
const currentUser = await getUserFromToken(token)
|
const currentUser = await getUserFromToken(token)
|
||||||
|
|
||||||
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
|
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Zugriff verweigert'
|
message: 'Zugriff verweigert'
|
||||||
@@ -15,16 +15,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
|
|
||||||
// Return users without passwords
|
// Return users without passwords
|
||||||
const safeUsers = users.map(u => ({
|
const safeUsers = users.map(u => {
|
||||||
id: u.id,
|
const migrated = migrateUserRoles({ ...u })
|
||||||
email: u.email,
|
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
|
||||||
name: u.name,
|
return {
|
||||||
role: u.role,
|
id: u.id,
|
||||||
phone: u.phone || '',
|
email: u.email,
|
||||||
active: u.active,
|
name: u.name,
|
||||||
created: u.created,
|
roles: roles,
|
||||||
lastLogin: u.lastLogin
|
role: roles[0] || 'mitglied', // Rückwärtskompatibilität
|
||||||
}))
|
phone: u.phone || '',
|
||||||
|
active: u.active,
|
||||||
|
created: u.created,
|
||||||
|
lastLogin: u.lastLogin
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: safeUsers
|
users: safeUsers
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token')
|
||||||
const currentUser = await getUserFromToken(token)
|
const currentUser = await getUserFromToken(token)
|
||||||
|
|
||||||
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
|
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Zugriff verweigert'
|
message: 'Zugriff verweigert'
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token')
|
||||||
const currentUser = await getUserFromToken(token)
|
const currentUser = await getUserFromToken(token)
|
||||||
|
|
||||||
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
|
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Zugriff verweigert'
|
message: 'Zugriff verweigert'
|
||||||
@@ -13,12 +13,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { userId, role } = body
|
const { userId, roles } = body
|
||||||
|
|
||||||
if (!['mitglied', 'vorstand', 'admin'].includes(role)) {
|
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter']
|
||||||
|
const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied'])
|
||||||
|
|
||||||
|
if (!rolesArray.every(r => validRoles.includes(r))) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Ungültige Rolle'
|
message: 'Ungültige Rolle(n)'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +35,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
user.role = role
|
// Migriere Benutzer falls nötig
|
||||||
|
migrateUserRoles(user)
|
||||||
|
|
||||||
|
// Setze Rollen
|
||||||
|
user.roles = rolesArray
|
||||||
const updatedUsers = users.map(u => u.id === userId ? user : u)
|
const updatedUsers = users.map(u => u.id === userId ? user : u)
|
||||||
await writeUsers(updatedUsers)
|
await writeUsers(updatedUsers)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can edit config
|
// Only admin and vorstand can edit config
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Bearbeiten der Konfiguration.'
|
message: 'Keine Berechtigung zum Bearbeiten der Konfiguration.'
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUserFromToken(token)
|
const user = await getUserFromToken(token)
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Keine Berechtigung zum Löschen von Bildern'
|
statusMessage: 'Keine Berechtigung zum Löschen von Bildern'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import multer from 'multer'
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
import { getUserFromToken, verifyToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
@@ -90,7 +90,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUserFromToken(token)
|
const user = await getUserFromToken(token)
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'
|
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { deleteMember } from '../utils/members.js'
|
import { deleteMember } from '../utils/members.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can delete members
|
// Only admin and vorstand can delete members
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Löschen von Mitgliedern.'
|
message: 'Keine Berechtigung zum Löschen von Mitgliedern.'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { verifyToken } from '../utils/auth.js'
|
import { verifyToken } from '../utils/auth.js'
|
||||||
import { readMembers } from '../utils/members.js'
|
import { readMembers } from '../utils/members.js'
|
||||||
import { readUsers } from '../utils/auth.js'
|
import { readUsers, migrateUserRoles } from '../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -75,28 +75,36 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
if (matchedManualIndex !== -1) {
|
if (matchedManualIndex !== -1) {
|
||||||
// Merge with existing manual member
|
// Merge with existing manual member
|
||||||
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
mergedMembers[matchedManualIndex] = {
|
mergedMembers[matchedManualIndex] = {
|
||||||
...mergedMembers[matchedManualIndex],
|
...mergedMembers[matchedManualIndex],
|
||||||
hasLogin: true,
|
hasLogin: true,
|
||||||
loginEmail: user.email,
|
loginEmail: user.email,
|
||||||
loginRole: user.role,
|
loginRoles: roles,
|
||||||
lastLogin: user.lastLogin
|
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
|
||||||
|
lastLogin: user.lastLogin,
|
||||||
|
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add as new member (from login system)
|
// Add as new member (from login system)
|
||||||
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
mergedMembers.push({
|
mergedMembers.push({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
address: '',
|
address: '',
|
||||||
notes: `Rolle: ${user.role}`,
|
notes: `Rolle(n): ${roles.join(', ')}`,
|
||||||
source: 'login',
|
source: 'login',
|
||||||
editable: false,
|
editable: false,
|
||||||
hasLogin: true,
|
hasLogin: true,
|
||||||
loginEmail: user.email,
|
loginEmail: user.email,
|
||||||
loginRole: user.role,
|
loginRoles: roles,
|
||||||
lastLogin: user.lastLogin
|
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
|
||||||
|
lastLogin: user.lastLogin,
|
||||||
|
isMannschaftsspieler: user.isMannschaftsspieler === true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveMember } from '../utils/members.js'
|
import { saveMember } from '../utils/members.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only admin and vorstand can add/edit members
|
// Only admin and vorstand can add/edit members
|
||||||
if (user.role !== 'admin' && user.role !== 'vorstand') {
|
if (!hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Hinzufügen/Bearbeiten von Mitgliedern. Erforderlich: admin oder vorstand Rolle.'
|
message: 'Keine Berechtigung zum Hinzufügen/Bearbeiten von Mitgliedern. Erforderlich: admin oder vorstand Rolle.'
|
||||||
@@ -48,7 +48,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes } = body
|
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler } = body
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -73,7 +73,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
email: email || '',
|
email: email || '',
|
||||||
phone: phone || '',
|
phone: phone || '',
|
||||||
address: address || '',
|
address: address || '',
|
||||||
notes: notes || ''
|
notes: notes || '',
|
||||||
|
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true'
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../../utils/auth.js'
|
||||||
import { readMembers, writeMembers, normalizeDate } from '../../utils/members.js'
|
import { readMembers, writeMembers, normalizeDate } from '../../utils/members.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only admin and vorstand can add members in bulk
|
// Only admin and vorstand can add members in bulk
|
||||||
if (user.role !== 'admin' && user.role !== 'vorstand') {
|
if (!hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Bulk-Import von Mitgliedern. Erforderlich: admin oder vorstand Rolle.'
|
message: 'Keine Berechtigung zum Bulk-Import von Mitgliedern. Erforderlich: admin oder vorstand Rolle.'
|
||||||
|
|||||||
105
server/api/members/toggle-mannschaftsspieler.post.js
Normal file
105
server/api/members/toggle-mannschaftsspieler.post.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { verifyToken, getUserById, hasAnyRole, readUsers, writeUsers } from '../../utils/auth.js'
|
||||||
|
import { readMembers, writeMembers, getMemberById } from '../../utils/members.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Support both Cookie and Authorization Header
|
||||||
|
let token = getCookie(event, 'auth_token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const authHeader = getHeader(event, 'authorization')
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
token = authHeader.substring(7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Nicht authentifiziert.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Ungültiges Token.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Benutzer nicht gefunden.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin and vorstand can toggle this flag
|
||||||
|
if (!hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Keine Berechtigung.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { memberId } = body
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Mitglieds-ID ist erforderlich.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob es ein Login-System-Mitglied ist (user.id === memberId)
|
||||||
|
const users = await readUsers()
|
||||||
|
const loginUser = users.find(u => u.id === memberId)
|
||||||
|
|
||||||
|
if (loginUser) {
|
||||||
|
// Login-System-Mitglied: Flag in users.json speichern
|
||||||
|
loginUser.isMannschaftsspieler = !loginUser.isMannschaftsspieler
|
||||||
|
await writeUsers(users)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Mannschaftsspieler-Status aktualisiert.',
|
||||||
|
isMannschaftsspieler: loginUser.isMannschaftsspieler
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Manuelles Mitglied: Flag in members.json speichern
|
||||||
|
const members = await readMembers()
|
||||||
|
const member = members.find(m => m.id === memberId)
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Mitglied nicht gefunden.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
member.isMannschaftsspieler = !member.isMannschaftsspieler
|
||||||
|
await writeMembers(members)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Mannschaftsspieler-Status aktualisiert.',
|
||||||
|
isMannschaftsspieler: member.isMannschaftsspieler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Umschalten des Mannschaftsspieler-Status:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
message: error.message || 'Fehler beim Umschalten des Mannschaftsspieler-Status.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@@ -39,7 +39,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
// Authentifizierte Benutzer prüfen
|
// Authentifizierte Benutzer prüfen
|
||||||
const user = await getUserFromToken(token)
|
const user = await getUserFromToken(token)
|
||||||
if (user && ['admin', 'vorstand'].includes(user.role)) {
|
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
if (user && (roles.includes('admin') || roles.includes('vorstand'))) {
|
||||||
// Admin/Vorstand kann alle Dateien herunterladen
|
// Admin/Vorstand kann alle Dateien herunterladen
|
||||||
isAuthorized = true
|
isAuthorized = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { deleteNews } from '../utils/news.js'
|
import { deleteNews } from '../utils/news.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can delete news
|
// Only admin and vorstand can delete news
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Löschen von News.'
|
message: 'Keine Berechtigung zum Löschen von News.'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveNews } from '../utils/news.js'
|
import { saveNews } from '../utils/news.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can create/edit news
|
// Only admin and vorstand can create/edit news
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Erstellen/Bearbeiten von News.'
|
message: 'Keine Berechtigung zum Erstellen/Bearbeiten von News.'
|
||||||
|
|||||||
89
server/api/newsletter/[id].delete.js
Normal file
89
server/api/newsletter/[id].delete.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTERS_FILE = getDataPath('newsletters.json')
|
||||||
|
|
||||||
|
async function readNewsletters() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTERS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeNewsletters(newsletters) {
|
||||||
|
await fs.writeFile(NEWSLETTERS_FILE, JSON.stringify(newsletters, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsletterId = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const newsletters = await readNewsletters()
|
||||||
|
const newsletterIndex = newsletters.findIndex(n => n.id === newsletterId)
|
||||||
|
|
||||||
|
if (newsletterIndex === -1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Entwürfe können gelöscht werden
|
||||||
|
if (newsletters[newsletterIndex].status === 'sent') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Versendete Newsletter können nicht gelöscht werden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newsletters.splice(newsletterIndex, 1)
|
||||||
|
await writeNewsletters(newsletters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Newsletter erfolgreich gelöscht'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen des Newsletters:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Löschen des Newsletters'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
98
server/api/newsletter/[id].put.js
Normal file
98
server/api/newsletter/[id].put.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTERS_FILE = getDataPath('newsletters.json')
|
||||||
|
|
||||||
|
async function readNewsletters() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTERS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeNewsletters(newsletters) {
|
||||||
|
await fs.writeFile(NEWSLETTERS_FILE, JSON.stringify(newsletters, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsletterId = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const newsletters = await readNewsletters()
|
||||||
|
const newsletterIndex = newsletters.findIndex(n => n.id === newsletterId)
|
||||||
|
|
||||||
|
if (newsletterIndex === -1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Entwürfe können bearbeitet werden
|
||||||
|
if (newsletters[newsletterIndex].status === 'sent') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Versendete Newsletter können nicht bearbeitet werden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Newsletter
|
||||||
|
newsletters[newsletterIndex] = {
|
||||||
|
...newsletters[newsletterIndex],
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy: user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeNewsletters(newsletters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Newsletter erfolgreich aktualisiert',
|
||||||
|
newsletter: newsletters[newsletterIndex]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Aktualisieren des Newsletters:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Aktualisieren des Newsletters'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
268
server/api/newsletter/[id]/send.post.js
Normal file
268
server/api/newsletter/[id]/send.post.js
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||||
|
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../utils/newsletter.js'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTERS_FILE = getDataPath('newsletters.json')
|
||||||
|
|
||||||
|
async function readNewsletters() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTERS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeNewsletters(newsletters) {
|
||||||
|
await fs.writeFile(NEWSLETTERS_FILE, JSON.stringify(newsletters, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lädt Config für Logo und Clubname
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = getDataPath('config.json')
|
||||||
|
const data = await fs.readFile(configPath, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
verein: { name: 'Harheimer Tischtennis-Club 1954 e.V.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstellt Newsletter-HTML mit Header und Footer
|
||||||
|
async function createNewsletterHTML(newsletter, unsubscribeToken = null) {
|
||||||
|
const config = await loadConfig()
|
||||||
|
const clubName = config.verein?.name || 'Harheimer Tischtennis-Club 1954 e.V.'
|
||||||
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||||
|
|
||||||
|
let unsubscribeLink = ''
|
||||||
|
if (unsubscribeToken) {
|
||||||
|
const unsubscribeUrl = `${baseUrl}/newsletter/unsubscribe?token=${unsubscribeToken}`
|
||||||
|
unsubscribeLink = `
|
||||||
|
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 12px;">
|
||||||
|
<p>Sie erhalten diese E-Mail, weil Sie sich für unseren Newsletter angemeldet haben.</p>
|
||||||
|
<p style="margin-top: 10px;">
|
||||||
|
<a href="${unsubscribeUrl}" style="color: #dc2626; text-decoration: underline;">Newsletter abmelden</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f3f4f6;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #dc2626; padding: 30px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold;">
|
||||||
|
${clubName}
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #111827; font-size: 20px;">
|
||||||
|
${newsletter.title}
|
||||||
|
</h2>
|
||||||
|
<div style="color: #374151; line-height: 1.6;">
|
||||||
|
${newsletter.content}
|
||||||
|
</div>
|
||||||
|
${unsubscribeLink}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f9fafb; padding: 20px; text-align: center; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0;">
|
||||||
|
${clubName}<br>
|
||||||
|
<a href="${baseUrl}" style="color: #dc2626; text-decoration: none;">${baseUrl}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsletterId = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const newsletters = await readNewsletters()
|
||||||
|
const newsletterIndex = newsletters.findIndex(n => n.id === newsletterId)
|
||||||
|
|
||||||
|
if (newsletterIndex === -1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsletter = newsletters[newsletterIndex]
|
||||||
|
|
||||||
|
if (newsletter.status === 'sent') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Newsletter wurde bereits versendet'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Newsletter Inhalt hat
|
||||||
|
if (!newsletter.content || newsletter.content.trim() === '' || newsletter.content === '<p><br></p>') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Newsletter hat keinen Inhalt. Bitte fügen Sie Inhalte hinzu, bevor Sie den Newsletter versenden.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP-Credentials prüfen
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
|
if (!smtpUser || !smtpPass) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'SMTP-Credentials fehlen! Bitte setzen Sie SMTP_USER und SMTP_PASS in der .env Datei.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Empfänger bestimmen
|
||||||
|
let recipients = []
|
||||||
|
|
||||||
|
if (newsletter.type === 'subscription') {
|
||||||
|
// Abonnenten-Newsletter
|
||||||
|
recipients = await getNewsletterSubscribers(!newsletter.sendToExternal)
|
||||||
|
} else if (newsletter.type === 'group') {
|
||||||
|
// Gruppen-Newsletter
|
||||||
|
recipients = await getRecipientsByGroup(newsletter.targetGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Keine Empfänger gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newsletter versenden
|
||||||
|
let sentCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
const failedEmails = []
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
try {
|
||||||
|
// Abmelde-Token generieren (nur für Abonnenten-Newsletter)
|
||||||
|
let unsubscribeToken = null
|
||||||
|
if (newsletter.type === 'subscription') {
|
||||||
|
unsubscribeToken = await generateUnsubscribeToken(recipient.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlContent = await createNewsletterHTML(newsletter, unsubscribeToken)
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
||||||
|
to: recipient.email,
|
||||||
|
subject: newsletter.title,
|
||||||
|
html: htmlContent
|
||||||
|
})
|
||||||
|
|
||||||
|
sentCount++
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Fehler beim Senden an ${recipient.email}:`, error)
|
||||||
|
failedCount++
|
||||||
|
failedEmails.push(recipient.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newsletter als versendet markieren
|
||||||
|
newsletters[newsletterIndex].status = 'sent'
|
||||||
|
newsletters[newsletterIndex].sentAt = new Date().toISOString()
|
||||||
|
newsletters[newsletterIndex].sentTo = {
|
||||||
|
total: recipients.length,
|
||||||
|
sent: sentCount,
|
||||||
|
failed: failedCount,
|
||||||
|
failedEmails: failedEmails.length > 0 ? failedEmails : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeNewsletters(newsletters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Newsletter erfolgreich versendet`,
|
||||||
|
stats: {
|
||||||
|
total: recipients.length,
|
||||||
|
sent: sentCount,
|
||||||
|
failed: failedCount,
|
||||||
|
failedEmails: failedEmails.length > 0 ? failedEmails : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Versenden des Newsletters:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Fehler beim Versenden des Newsletters'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
38
server/api/newsletter/check-subscription.get.js
Normal file
38
server/api/newsletter/check-subscription.get.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { readSubscribers } from '../../utils/newsletter.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const { email, groupId } = query
|
||||||
|
|
||||||
|
if (!email || !groupId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'E-Mail und Gruppen-ID sind erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const emailLower = email.toLowerCase()
|
||||||
|
|
||||||
|
const subscriber = subscribers.find(s => {
|
||||||
|
const sEmail = (s.email || '').toLowerCase()
|
||||||
|
return sEmail === emailLower && s.groupIds && s.groupIds.includes(groupId) && s.confirmed && !s.unsubscribedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
subscribed: !!subscriber
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Prüfen der Newsletter-Anmeldung:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Prüfen der Newsletter-Anmeldung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
62
server/api/newsletter/confirm.get.js
Normal file
62
server/api/newsletter/confirm.get.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const token = query.token
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Bestätigungstoken fehlt'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const subscriber = subscribers.find(s => s.confirmationToken === token)
|
||||||
|
|
||||||
|
if (!subscriber) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Ungültiger Bestätigungstoken'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriber.confirmed) {
|
||||||
|
// Bereits bestätigt
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
alreadyConfirmed: true,
|
||||||
|
message: 'Newsletter-Anmeldung wurde bereits bestätigt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestätigung durchführen
|
||||||
|
subscriber.confirmed = true
|
||||||
|
subscriber.confirmedAt = new Date().toISOString()
|
||||||
|
subscriber.confirmationToken = null
|
||||||
|
|
||||||
|
// Stelle sicher, dass groupIds existiert (für Rückwärtskompatibilität)
|
||||||
|
if (!subscriber.groupIds) {
|
||||||
|
subscriber.groupIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
alreadyConfirmed: false,
|
||||||
|
message: 'Newsletter-Anmeldung erfolgreich bestätigt'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler bei Newsletter-Bestätigung:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler bei der Newsletter-Bestätigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
115
server/api/newsletter/create.post.js
Normal file
115
server/api/newsletter/create.post.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTERS_FILE = getDataPath('newsletters.json')
|
||||||
|
|
||||||
|
async function readNewsletters() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTERS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeNewsletters(newsletters) {
|
||||||
|
await fs.writeFile(NEWSLETTERS_FILE, JSON.stringify(newsletters, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { title, content, type, targetGroup, sendToExternal } = body
|
||||||
|
|
||||||
|
if (!title || !type) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Titel und Typ sind erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'subscription' && sendToExternal === undefined) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'sendToExternal ist für Abonnenten-Newsletter erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'group' && !targetGroup) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Zielgruppe ist für Gruppen-Newsletter erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsletters = await readNewsletters()
|
||||||
|
|
||||||
|
const newNewsletter = {
|
||||||
|
id: randomUUID(),
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
type, // 'subscription' oder 'group'
|
||||||
|
targetGroup: type === 'group' ? targetGroup : null, // 'alle', 'erwachsene', 'nachwuchs', 'mannschaftsspieler', 'vorstand'
|
||||||
|
sendToExternal: type === 'subscription' ? sendToExternal : false, // true = auch extern, false = nur intern
|
||||||
|
status: 'draft', // 'draft', 'sent'
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: user.id,
|
||||||
|
sentAt: null,
|
||||||
|
sentTo: null
|
||||||
|
}
|
||||||
|
|
||||||
|
newsletters.push(newNewsletter)
|
||||||
|
await writeNewsletters(newsletters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Newsletter erfolgreich erstellt',
|
||||||
|
newsletter: {
|
||||||
|
id: newNewsletter.id,
|
||||||
|
title: newNewsletter.title,
|
||||||
|
type: newNewsletter.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen des Newsletters:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Erstellen des Newsletters'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
401
server/api/newsletter/groups/[id]/posts/create.post.js
Normal file
401
server/api/newsletter/groups/[id]/posts/create.post.js
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../../../utils/newsletter.js'
|
||||||
|
import { encryptObject, decryptObject } from '../../../../../utils/encryption.js'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
const NEWSLETTER_POSTS_FILE = getDataPath('newsletter-posts.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeGroups(groups) {
|
||||||
|
await fs.writeFile(NEWSLETTER_GROUPS_FILE, JSON.stringify(groups, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPosts() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_POSTS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePosts(posts) {
|
||||||
|
await fs.writeFile(NEWSLETTER_POSTS_FILE, JSON.stringify(posts, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lädt Config für Logo und Clubname
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = getDataPath('config.json')
|
||||||
|
const data = await fs.readFile(configPath, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
verein: { name: 'Harheimer Tischtennis-Club 1954 e.V.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lädt Logo als Base64
|
||||||
|
async function loadLogoAsBase64() {
|
||||||
|
try {
|
||||||
|
const logoPath = path.join(process.cwd(), 'public', 'images', 'logos', 'Harheimer TC.svg')
|
||||||
|
const logoData = await fs.readFile(logoPath, 'utf-8')
|
||||||
|
// SVG als Base64 kodieren
|
||||||
|
const base64Logo = Buffer.from(logoData).toString('base64')
|
||||||
|
return `data:image/svg+xml;base64,${base64Logo}`
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden des Logos:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstellt Newsletter-HTML mit Header und Footer
|
||||||
|
async function createNewsletterHTML(post, group, unsubscribeToken = null, creatorName = null, creatorEmail = null) {
|
||||||
|
const config = await loadConfig()
|
||||||
|
const clubName = config.verein?.name || 'Harheimer Tischtennis-Club 1954 e.V.'
|
||||||
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||||
|
|
||||||
|
// Logo als Base64 laden
|
||||||
|
const logoDataUri = await loadLogoAsBase64()
|
||||||
|
|
||||||
|
let unsubscribeLink = ''
|
||||||
|
if (unsubscribeToken) {
|
||||||
|
const unsubscribeUrl = `${baseUrl}/newsletter/unsubscribe?token=${unsubscribeToken}`
|
||||||
|
unsubscribeLink = `
|
||||||
|
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 12px;">
|
||||||
|
<p>Sie erhalten diese E-Mail, weil Sie sich für unseren Newsletter angemeldet haben.</p>
|
||||||
|
<p style="margin-top: 10px;">
|
||||||
|
<a href="${unsubscribeUrl}" style="color: #dc2626; text-decoration: underline;">Newsletter abmelden</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f3f4f6;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(to right, #111827, #991b1b, #111827); padding: 30px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td width="50" valign="middle" style="padding-right: 15px;">
|
||||||
|
${logoDataUri ? `<img src="${logoDataUri}" alt="Harheimer TC Logo" style="width: 50px; height: 50px; display: block;" />` : ''}
|
||||||
|
</td>
|
||||||
|
<td valign="middle">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold; font-family: 'Montserrat', Arial, sans-serif;">
|
||||||
|
Harheimer <span style="color: #fca5a5;">TC</span>
|
||||||
|
</h1>
|
||||||
|
<p style="margin: 5px 0 0 0; color: #e5e7eb; font-size: 14px;">
|
||||||
|
${clubName}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #111827; font-size: 20px;">
|
||||||
|
${post.title}
|
||||||
|
</h2>
|
||||||
|
<div style="color: #374151; line-height: 1.6;">
|
||||||
|
${post.content}
|
||||||
|
</div>
|
||||||
|
${unsubscribeLink}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f9fafb; padding: 20px; text-align: center; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0;">
|
||||||
|
${clubName}<br>
|
||||||
|
<a href="${baseUrl}" style="color: #dc2626; text-decoration: none;">${baseUrl}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { title, content } = body
|
||||||
|
|
||||||
|
// Creator-Informationen für Absender
|
||||||
|
const creatorName = user.name || 'Harheimer TC'
|
||||||
|
const creatorEmail = user.email || process.env.SMTP_FROM || 'noreply@harheimertc.de'
|
||||||
|
|
||||||
|
if (!title || !content || (!content.trim() || content === '<p><br></p>')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Titel und Inhalt sind erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade Gruppe
|
||||||
|
const groups = await readGroups()
|
||||||
|
const group = groups.find(g => g.id === groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter-Gruppe nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP-Credentials prüfen
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
|
if (!smtpUser || !smtpPass) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'SMTP-Credentials fehlen! Bitte setzen Sie SMTP_USER und SMTP_PASS in der .env Datei.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Empfänger bestimmen
|
||||||
|
let recipients = []
|
||||||
|
|
||||||
|
if (group.type === 'subscription') {
|
||||||
|
// Abonnenten-Newsletter
|
||||||
|
recipients = await getNewsletterSubscribers(!group.sendToExternal, group.id)
|
||||||
|
} else if (group.type === 'group') {
|
||||||
|
// Gruppen-Newsletter
|
||||||
|
recipients = await getRecipientsByGroup(group.targetGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn keine Empfänger gefunden, Post trotzdem erstellen (aber nicht versenden)
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
// Post ohne Versand erstellen
|
||||||
|
const posts = await readPosts()
|
||||||
|
const newPost = {
|
||||||
|
id: randomUUID(),
|
||||||
|
groupId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: user.id,
|
||||||
|
sentAt: null,
|
||||||
|
sentTo: {
|
||||||
|
total: 0,
|
||||||
|
sent: 0,
|
||||||
|
failed: 0,
|
||||||
|
recipients: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posts.push(newPost)
|
||||||
|
await writePosts(posts)
|
||||||
|
|
||||||
|
// Post-Count in Gruppe erhöhen
|
||||||
|
group.postCount = (group.postCount || 0) + 1
|
||||||
|
await writeGroups(groups)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Post erfolgreich erstellt (keine Empfänger gefunden)',
|
||||||
|
post: {
|
||||||
|
id: newPost.id,
|
||||||
|
title: newPost.title,
|
||||||
|
groupId: newPost.groupId
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
total: 0,
|
||||||
|
sent: 0,
|
||||||
|
failed: 0,
|
||||||
|
recipients: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post erstellen
|
||||||
|
const posts = await readPosts()
|
||||||
|
const newPost = {
|
||||||
|
id: randomUUID(),
|
||||||
|
groupId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: user.id,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
sentTo: {
|
||||||
|
total: recipients.length,
|
||||||
|
sent: 0,
|
||||||
|
failed: 0,
|
||||||
|
failedEmails: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newsletter versenden
|
||||||
|
let sentCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
const failedEmails = []
|
||||||
|
const errorDetails = []
|
||||||
|
|
||||||
|
console.log(`Versende Newsletter an ${recipients.length} Empfänger...`)
|
||||||
|
console.log('Empfänger:', recipients.map(r => r.email))
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
try {
|
||||||
|
// Validiere E-Mail-Adresse
|
||||||
|
if (!recipient.email || !recipient.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||||
|
throw new Error(`Ungültige E-Mail-Adresse: ${recipient.email}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abmelde-Token generieren (nur für Abonnenten-Newsletter)
|
||||||
|
let unsubscribeToken = null
|
||||||
|
if (group.type === 'subscription') {
|
||||||
|
unsubscribeToken = await generateUnsubscribeToken(recipient.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlContent = await createNewsletterHTML(newPost, group, unsubscribeToken, creatorName, creatorEmail)
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
|
from: `"${creatorName}" <${creatorEmail}>`,
|
||||||
|
replyTo: creatorEmail,
|
||||||
|
to: recipient.email,
|
||||||
|
subject: title,
|
||||||
|
html: htmlContent
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`✅ Erfolgreich versendet an ${recipient.email}:`, mailResult.messageId)
|
||||||
|
sentCount++
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error.message || error.toString()
|
||||||
|
console.error(`❌ Fehler beim Senden an ${recipient.email}:`, errorMsg)
|
||||||
|
failedCount++
|
||||||
|
failedEmails.push(recipient.email)
|
||||||
|
errorDetails.push({
|
||||||
|
email: recipient.email,
|
||||||
|
error: errorMsg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Versand abgeschlossen: ${sentCount} erfolgreich, ${failedCount} fehlgeschlagen`)
|
||||||
|
|
||||||
|
// Post speichern mit Versand-Statistik und Empfängerliste
|
||||||
|
newPost.sentTo = {
|
||||||
|
total: recipients.length,
|
||||||
|
sent: sentCount,
|
||||||
|
failed: failedCount,
|
||||||
|
failedEmails: failedEmails.length > 0 ? failedEmails : undefined,
|
||||||
|
errorDetails: errorDetails.length > 0 ? errorDetails : undefined,
|
||||||
|
recipients: recipients.map(r => ({
|
||||||
|
email: r.email,
|
||||||
|
name: r.name || '',
|
||||||
|
sent: !failedEmails.includes(r.email)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
posts.push(newPost)
|
||||||
|
await writePosts(posts)
|
||||||
|
|
||||||
|
// Post-Count in Gruppe erhöhen
|
||||||
|
group.postCount = (group.postCount || 0) + 1
|
||||||
|
await writeGroups(groups)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Post erfolgreich erstellt und versendet`,
|
||||||
|
post: {
|
||||||
|
id: newPost.id,
|
||||||
|
title: newPost.title,
|
||||||
|
groupId: newPost.groupId
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
total: recipients.length,
|
||||||
|
sent: sentCount,
|
||||||
|
failed: failedCount,
|
||||||
|
failedEmails: failedEmails.length > 0 ? failedEmails : undefined,
|
||||||
|
errorDetails: errorDetails.length > 0 ? errorDetails : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen und Versenden des Posts:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Fehler beim Erstellen und Versenden des Posts'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
113
server/api/newsletter/groups/[id]/posts/list.get.js
Normal file
113
server/api/newsletter/groups/[id]/posts/list.get.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
|
import { encryptObject, decryptObject } from '../../../../../utils/encryption.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_POSTS_FILE = getDataPath('newsletter-posts.json')
|
||||||
|
|
||||||
|
function getEncryptionKey() {
|
||||||
|
return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft ob Daten verschlüsselt sind
|
||||||
|
function isEncrypted(data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.trim())
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPosts() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_POSTS_FILE, 'utf-8')
|
||||||
|
const encrypted = isEncrypted(data)
|
||||||
|
|
||||||
|
if (encrypted) {
|
||||||
|
const encryptionKey = getEncryptionKey()
|
||||||
|
try {
|
||||||
|
return decryptObject(data, encryptionKey)
|
||||||
|
} catch (decryptError) {
|
||||||
|
console.error('Fehler beim Entschlüsseln der Newsletter-Posts:', decryptError)
|
||||||
|
try {
|
||||||
|
const plainData = JSON.parse(data)
|
||||||
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
||||||
|
return plainData
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Konnte Newsletter-Posts weder entschlüsseln noch als JSON lesen')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain JSON - migriere zu verschlüsselter Speicherung
|
||||||
|
const posts = JSON.parse(data)
|
||||||
|
console.log('Migriere unverschlüsselte Newsletter-Posts zu verschlüsselter Speicherung...')
|
||||||
|
// Schreiben wird hier nicht gemacht, da wir nur lesen
|
||||||
|
return posts
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = getRouterParam(event, 'id')
|
||||||
|
const posts = await readPosts()
|
||||||
|
|
||||||
|
// Filtere Posts nach Gruppe und sortiere nach Datum (neueste zuerst)
|
||||||
|
const groupPosts = posts
|
||||||
|
.filter(p => p.groupId === groupId)
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
posts: groupPosts
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Posts:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden der Posts'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
254
server/api/newsletter/groups/[id]/subscribers/add.post.js
Normal file
254
server/api/newsletter/groups/[id]/subscribers/add.post.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
|
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email, name, customMessage } = body
|
||||||
|
|
||||||
|
if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Ungültige E-Mail-Adresse'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Gruppe existiert und vom Typ 'subscription' ist
|
||||||
|
const groups = await readGroups()
|
||||||
|
const group = groups.find(g => g.id === groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter-Gruppe nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.type !== 'subscription') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Diese Funktion ist nur für Abonnenten-Newsletter verfügbar'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const emailLower = email.toLowerCase()
|
||||||
|
|
||||||
|
// Prüfe ob bereits für diese Gruppe angemeldet
|
||||||
|
const existing = subscribers.find(s => {
|
||||||
|
const sEmail = (s.email || '').toLowerCase()
|
||||||
|
return sEmail === emailLower && s.groupIds && s.groupIds.includes(groupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.confirmed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Diese E-Mail-Adresse ist bereits für diesen Newsletter angemeldet'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Bestätigungsmail erneut senden mit individueller Nachricht
|
||||||
|
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob E-Mail bereits existiert (für andere Gruppe oder ohne Gruppe)
|
||||||
|
const existingEmail = subscribers.find(s => (s.email || '').toLowerCase() === emailLower)
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
// Bestehender Subscriber - Gruppe hinzufügen
|
||||||
|
if (!existingEmail.groupIds) {
|
||||||
|
existingEmail.groupIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingEmail.groupIds.includes(groupId)) {
|
||||||
|
// Bereits für diese Gruppe angemeldet
|
||||||
|
if (existingEmail.confirmed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Diese E-Mail-Adresse ist bereits für diesen Newsletter angemeldet'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Bestätigungsmail erneut senden mit individueller Nachricht
|
||||||
|
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppe hinzufügen
|
||||||
|
existingEmail.groupIds.push(groupId)
|
||||||
|
if (!existingEmail.confirmed) {
|
||||||
|
// Neuer Bestätigungstoken für alle Gruppen
|
||||||
|
existingEmail.confirmationToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
}
|
||||||
|
existingEmail.name = name || existingEmail.name || ''
|
||||||
|
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
if (existingEmail.confirmed) {
|
||||||
|
// Bereits bestätigt - sofort aktiviert
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Empfänger wurde erfolgreich für den Newsletter "${group.name}" hinzugefügt`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bestätigungsmail senden mit individueller Nachricht
|
||||||
|
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuer Abonnent
|
||||||
|
const confirmationToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const unsubscribeToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const newSubscriber = {
|
||||||
|
id: randomUUID(),
|
||||||
|
email: emailLower,
|
||||||
|
name: name || '',
|
||||||
|
groupIds: [groupId],
|
||||||
|
confirmed: false,
|
||||||
|
confirmationToken,
|
||||||
|
unsubscribeToken,
|
||||||
|
subscribedAt: new Date().toISOString(),
|
||||||
|
confirmedAt: null,
|
||||||
|
unsubscribedAt: null
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribers.push(newSubscriber)
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
// Bestätigungsmail senden mit individueller Nachricht
|
||||||
|
await sendConfirmationEmail(email, name, confirmationToken, group.name, customMessage, user.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Hinzufügen des Empfängers:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Hinzufügen des Empfängers'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sendConfirmationEmail(email, name, token, groupName, customMessage = null, inviterName = null) {
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
|
if (!smtpUser || !smtpPass) {
|
||||||
|
console.warn('SMTP-Credentials fehlen! Bestätigungsmail kann nicht gesendet werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||||
|
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Individuelle Nachricht einbauen, falls vorhanden
|
||||||
|
const customMessageHtml = customMessage
|
||||||
|
? `<div style="background-color: #f3f4f6; padding: 15px; border-left: 4px solid #dc2626; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #374151; font-style: italic;">${customMessage.replace(/\n/g, '<br>')}</p>
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const inviterText = inviterName
|
||||||
|
? `<p style="margin-top: 20px; color: #666; font-size: 14px;">Sie wurden von ${inviterName} zum Newsletter eingeladen.</p>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
||||||
|
to: email,
|
||||||
|
subject: `Newsletter-Anmeldung bestätigen - ${groupName} - Harheimer TC`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #dc2626;">Newsletter-Anmeldung bestätigen</h2>
|
||||||
|
<p>Hallo ${name || 'Liebe/r Abonnent/in'},</p>
|
||||||
|
${inviterText}
|
||||||
|
<p>vielen Dank für Ihre Anmeldung zum Newsletter "${groupName}" des Harheimer TC!</p>
|
||||||
|
${customMessageHtml}
|
||||||
|
<p>Bitte bestätigen Sie Ihre Anmeldung, indem Sie auf den folgenden Link klicken:</p>
|
||||||
|
<p style="margin: 30px 0;">
|
||||||
|
<a href="${confirmationUrl}" style="display: inline-block; padding: 12px 24px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 5px;">
|
||||||
|
Newsletter-Anmeldung bestätigen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>Falls Sie sich nicht angemeldet haben, können Sie diese E-Mail ignorieren.</p>
|
||||||
|
<p style="margin-top: 30px; color: #666; font-size: 12px;">
|
||||||
|
Mit sportlichen Grüßen,<br>
|
||||||
|
Ihr Harheimer Tischtennis-Club 1954 e.V.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
115
server/api/newsletter/groups/[id]/subscribers/list.get.js
Normal file
115
server/api/newsletter/groups/[id]/subscribers/list.get.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
|
import { readSubscribers } from '../../../../../utils/newsletter.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
// Prüfe ob Gruppe existiert und vom Typ 'subscription' ist
|
||||||
|
const groups = await readGroups()
|
||||||
|
const group = groups.find(g => g.id === groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter-Gruppe nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.type !== 'subscription') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Diese Funktion ist nur für Abonnenten-Newsletter verfügbar'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade alle Abonnenten
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
|
||||||
|
// Filtere Abonnenten für diese Gruppe
|
||||||
|
const groupSubscribers = subscribers
|
||||||
|
.filter(s => {
|
||||||
|
// Stelle sicher, dass groupIds existiert (für Rückwärtskompatibilität)
|
||||||
|
if (!s.groupIds || !Array.isArray(s.groupIds)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.groupIds.includes(groupId)
|
||||||
|
})
|
||||||
|
.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
email: s.email,
|
||||||
|
name: s.name || '',
|
||||||
|
confirmed: s.confirmed || false,
|
||||||
|
subscribedAt: s.subscribedAt || null,
|
||||||
|
confirmedAt: s.confirmedAt || null,
|
||||||
|
unsubscribedAt: s.unsubscribedAt || null
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sortiere nach bestätigt, dann nach Datum
|
||||||
|
if (a.confirmed !== b.confirmed) {
|
||||||
|
return a.confirmed ? -1 : 1
|
||||||
|
}
|
||||||
|
if (a.subscribedAt && b.subscribedAt) {
|
||||||
|
return new Date(b.subscribedAt) - new Date(a.subscribedAt)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
subscribers: groupSubscribers
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Abonnenten:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden der Abonnenten'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
84
server/api/newsletter/groups/[id]/subscribers/remove.post.js
Normal file
84
server/api/newsletter/groups/[id]/subscribers/remove.post.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
|
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { subscriberId } = body
|
||||||
|
|
||||||
|
if (!subscriberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Abonnenten-ID ist erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const subscriber = subscribers.find(s => s.id === subscriberId)
|
||||||
|
|
||||||
|
if (!subscriber) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Abonnent nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stelle sicher, dass groupIds existiert
|
||||||
|
if (!subscriber.groupIds || !Array.isArray(subscriber.groupIds)) {
|
||||||
|
subscriber.groupIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne Gruppe aus groupIds
|
||||||
|
const index = subscriber.groupIds.indexOf(groupId)
|
||||||
|
if (index === -1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Abonnent ist nicht für diese Gruppe angemeldet'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriber.groupIds.splice(index, 1)
|
||||||
|
|
||||||
|
// Wenn keine Gruppen mehr vorhanden, als abgemeldet markieren
|
||||||
|
if (subscriber.groupIds.length === 0) {
|
||||||
|
subscriber.unsubscribedAt = new Date().toISOString()
|
||||||
|
subscriber.confirmed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Abonnent erfolgreich entfernt'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Entfernen des Abonnenten:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Entfernen des Abonnenten'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
117
server/api/newsletter/groups/create.post.js
Normal file
117
server/api/newsletter/groups/create.post.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeGroups(groups) {
|
||||||
|
await fs.writeFile(NEWSLETTER_GROUPS_FILE, JSON.stringify(groups, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { name, type, targetGroup, sendToExternal, description } = body
|
||||||
|
|
||||||
|
if (!name || !type) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Name und Typ sind erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'subscription' && sendToExternal === undefined) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'sendToExternal ist für Abonnenten-Newsletter erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'group' && !targetGroup) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Zielgruppe ist für Gruppen-Newsletter erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await readGroups()
|
||||||
|
|
||||||
|
// Prüfe ob Name bereits existiert
|
||||||
|
if (groups.find(g => g.name.toLowerCase() === name.toLowerCase())) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Eine Newsletter-Gruppe mit diesem Namen existiert bereits'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGroup = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name,
|
||||||
|
description: description || '',
|
||||||
|
type, // 'subscription' oder 'group'
|
||||||
|
targetGroup: type === 'group' ? targetGroup : null,
|
||||||
|
sendToExternal: type === 'subscription' ? sendToExternal : false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: user.id,
|
||||||
|
postCount: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push(newGroup)
|
||||||
|
await writeGroups(groups)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Newsletter-Gruppe erfolgreich erstellt',
|
||||||
|
group: newGroup
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen der Newsletter-Gruppe:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Erstellen der Newsletter-Gruppe'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
64
server/api/newsletter/groups/list.get.js
Normal file
64
server/api/newsletter/groups/list.get.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await readGroups()
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Newsletter-Gruppen:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden der Newsletter-Gruppen'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
73
server/api/newsletter/groups/public-list.get.js
Normal file
73
server/api/newsletter/groups/public-list.get.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Prüfe ob Benutzer eingeloggt ist
|
||||||
|
let isLoggedIn = false
|
||||||
|
try {
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
if (token) {
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (user && user.active) {
|
||||||
|
isLoggedIn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Nicht eingeloggt - kein Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await readGroups()
|
||||||
|
|
||||||
|
// Filtere Newsletter-Gruppen basierend auf Login-Status
|
||||||
|
let publicGroups
|
||||||
|
if (isLoggedIn) {
|
||||||
|
// Eingeloggte Benutzer sehen alle Abonnenten-Newsletter (intern und extern)
|
||||||
|
publicGroups = groups.filter(g => g.type === 'subscription')
|
||||||
|
} else {
|
||||||
|
// Nicht eingeloggte Benutzer sehen nur externe Newsletter
|
||||||
|
publicGroups = groups.filter(g =>
|
||||||
|
g.type === 'subscription' && g.sendToExternal === true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
groups: publicGroups.map(g => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
description: g.description || ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der öffentlichen Newsletter-Gruppen:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden der Newsletter-Gruppen'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
67
server/api/newsletter/list.get.js
Normal file
67
server/api/newsletter/list.get.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTERS_FILE = getDataPath('newsletters.json')
|
||||||
|
|
||||||
|
async function readNewsletters() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTERS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsletters = await readNewsletters()
|
||||||
|
|
||||||
|
// Sortiere nach Erstellungsdatum (neueste zuerst)
|
||||||
|
newsletters.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
newsletters
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Newsletter:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden der Newsletter'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
228
server/api/newsletter/subscribe.post.js
Normal file
228
server/api/newsletter/subscribe.post.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email, name, groupId } = body
|
||||||
|
|
||||||
|
if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Ungültige E-Mail-Adresse'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Newsletter-Gruppe muss ausgewählt werden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Gruppe existiert und für externe Abonnements verfügbar ist
|
||||||
|
const groups = await readGroups()
|
||||||
|
const group = groups.find(g => g.id === groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter-Gruppe nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.type !== 'subscription' || group.sendToExternal !== true) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Diese Newsletter-Gruppe ist nicht für externe Abonnements verfügbar'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const emailLower = email.toLowerCase()
|
||||||
|
|
||||||
|
// Prüfe ob bereits für diese Gruppe angemeldet
|
||||||
|
const existing = subscribers.find(s => {
|
||||||
|
const sEmail = (s.email || '').toLowerCase()
|
||||||
|
return sEmail === emailLower && s.groupIds && s.groupIds.includes(groupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.confirmed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Sie sind bereits für diesen Newsletter angemeldet'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Bestätigungsmail erneut senden
|
||||||
|
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob E-Mail bereits existiert (für andere Gruppe oder ohne Gruppe)
|
||||||
|
const existingEmail = subscribers.find(s => (s.email || '').toLowerCase() === emailLower)
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
// Bestehender Subscriber - Gruppe hinzufügen
|
||||||
|
if (!existingEmail.groupIds) {
|
||||||
|
existingEmail.groupIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingEmail.groupIds.includes(groupId)) {
|
||||||
|
// Bereits für diese Gruppe angemeldet
|
||||||
|
if (existingEmail.confirmed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Sie sind bereits für diesen Newsletter angemeldet'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Bestätigungsmail erneut senden
|
||||||
|
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppe hinzufügen
|
||||||
|
existingEmail.groupIds.push(groupId)
|
||||||
|
if (!existingEmail.confirmed) {
|
||||||
|
// Neuer Bestätigungstoken für alle Gruppen
|
||||||
|
existingEmail.confirmationToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
}
|
||||||
|
existingEmail.name = name || existingEmail.name || ''
|
||||||
|
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
if (existingEmail.confirmed) {
|
||||||
|
// Bereits bestätigt - sofort aktiviert
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Sie wurden erfolgreich für den Newsletter "${group.name}" angemeldet`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bestätigungsmail senden
|
||||||
|
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet. Bitte bestätigen Sie Ihre Anmeldung.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuer Abonnent
|
||||||
|
const confirmationToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const unsubscribeToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const newSubscriber = {
|
||||||
|
id: randomUUID(),
|
||||||
|
email: emailLower,
|
||||||
|
name: name || '',
|
||||||
|
groupIds: [groupId],
|
||||||
|
confirmed: false,
|
||||||
|
confirmationToken,
|
||||||
|
unsubscribeToken,
|
||||||
|
subscribedAt: new Date().toISOString(),
|
||||||
|
confirmedAt: null,
|
||||||
|
unsubscribedAt: null
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribers.push(newSubscriber)
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
// Bestätigungsmail senden
|
||||||
|
await sendConfirmationEmail(email, name, confirmationToken, group.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet. Bitte bestätigen Sie Ihre Anmeldung.'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler bei Newsletter-Anmeldung:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler bei der Newsletter-Anmeldung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sendConfirmationEmail(email, name, token, groupName) {
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
|
if (!smtpUser || !smtpPass) {
|
||||||
|
console.warn('SMTP-Credentials fehlen! Bestätigungsmail kann nicht gesendet werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||||
|
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
||||||
|
to: email,
|
||||||
|
subject: `Newsletter-Anmeldung bestätigen - ${groupName} - Harheimer TC`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #dc2626;">Newsletter-Anmeldung bestätigen</h2>
|
||||||
|
<p>Hallo ${name || 'Liebe/r Abonnent/in'},</p>
|
||||||
|
<p>vielen Dank für Ihre Anmeldung zum Newsletter "${groupName}" des Harheimer TC!</p>
|
||||||
|
<p>Bitte bestätigen Sie Ihre Anmeldung, indem Sie auf den folgenden Link klicken:</p>
|
||||||
|
<p style="margin: 30px 0;">
|
||||||
|
<a href="${confirmationUrl}" style="display: inline-block; padding: 12px 24px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 5px;">
|
||||||
|
Newsletter-Anmeldung bestätigen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>Falls Sie sich nicht angemeldet haben, können Sie diese E-Mail ignorieren.</p>
|
||||||
|
<p style="margin-top: 30px; color: #666; font-size: 12px;">
|
||||||
|
Mit sportlichen Grüßen,<br>
|
||||||
|
Ihr Harheimer Tischtennis-Club 1954 e.V.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
121
server/api/newsletter/unsubscribe-by-email.post.js
Normal file
121
server/api/newsletter/unsubscribe-by-email.post.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
||||||
|
|
||||||
|
async function readGroups() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email, groupId } = body
|
||||||
|
|
||||||
|
if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Ungültige E-Mail-Adresse'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Newsletter-Gruppe muss angegeben werden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Gruppe existiert
|
||||||
|
const groups = await readGroups()
|
||||||
|
const group = groups.find(g => g.id === groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Newsletter-Gruppe nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.type !== 'subscription') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Diese Funktion ist nur für Abonnenten-Newsletter verfügbar'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const emailLower = email.toLowerCase()
|
||||||
|
|
||||||
|
const subscriber = subscribers.find(s => {
|
||||||
|
const sEmail = (s.email || '').toLowerCase()
|
||||||
|
return sEmail === emailLower
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!subscriber) {
|
||||||
|
// Nicht gefunden - aber trotzdem Erfolg zurückgeben (keine Information preisgeben)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Sie wurden erfolgreich vom Newsletter abgemeldet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stelle sicher, dass groupIds existiert
|
||||||
|
if (!subscriber.groupIds || !Array.isArray(subscriber.groupIds)) {
|
||||||
|
subscriber.groupIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob für diese Gruppe angemeldet
|
||||||
|
if (!subscriber.groupIds.includes(groupId)) {
|
||||||
|
// Nicht für diese Gruppe angemeldet - aber trotzdem Erfolg zurückgeben
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Sie wurden erfolgreich vom Newsletter abgemeldet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne Gruppe aus groupIds
|
||||||
|
const index = subscriber.groupIds.indexOf(groupId)
|
||||||
|
subscriber.groupIds.splice(index, 1)
|
||||||
|
|
||||||
|
// Wenn keine Gruppen mehr vorhanden, als abgemeldet markieren
|
||||||
|
if (subscriber.groupIds.length === 0) {
|
||||||
|
subscriber.unsubscribedAt = new Date().toISOString()
|
||||||
|
subscriber.confirmed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Sie wurden erfolgreich vom Newsletter abgemeldet'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler bei Newsletter-Abmeldung:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler bei der Newsletter-Abmeldung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
56
server/api/newsletter/unsubscribe.get.js
Normal file
56
server/api/newsletter/unsubscribe.get.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const token = query.token
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Abmeldetoken fehlt'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const subscriber = subscribers.find(s => s.unsubscribeToken === token)
|
||||||
|
|
||||||
|
if (!subscriber) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Ungültiger Abmeldetoken'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriber.unsubscribedAt) {
|
||||||
|
// Bereits abgemeldet
|
||||||
|
return sendRedirect(event, '/newsletter/unsubscribed?already=true')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abmeldung durchführen
|
||||||
|
subscriber.unsubscribedAt = new Date().toISOString()
|
||||||
|
subscriber.confirmed = false
|
||||||
|
|
||||||
|
// Stelle sicher, dass groupIds existiert (für Rückwärtskompatibilität)
|
||||||
|
if (!subscriber.groupIds) {
|
||||||
|
subscriber.groupIds = []
|
||||||
|
}
|
||||||
|
// Leere groupIds, um von allen Gruppen abzumelden
|
||||||
|
subscriber.groupIds = []
|
||||||
|
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
|
// Weiterleitung zur Abmelde-Bestätigungsseite
|
||||||
|
return sendRedirect(event, '/newsletter/unsubscribed')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler bei Newsletter-Abmeldung:', error)
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler bei der Newsletter-Abmeldung'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
108
server/api/personen/[filename].get.js
Normal file
108
server/api/personen/[filename].get.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
// Handle both dev and production paths
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONEN_DIR = getDataPath('personen')
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const filename = getRouterParam(event, 'filename')
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Dateiname erforderlich'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicherheitsprüfung: Nur erlaubte Dateinamen (UUID-Format)
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|jpeg|png|gif|webp)$/i.test(filename)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Ungültiger Dateiname'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(PERSONEN_DIR, filename)
|
||||||
|
|
||||||
|
// Prüfe ob Datei existiert
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
} catch {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Bild nicht gefunden'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME-Type bestimmen
|
||||||
|
const ext = path.extname(filename).toLowerCase()
|
||||||
|
const mimeTypes = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp'
|
||||||
|
}
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
// Optional: Query-Parameter für Größe
|
||||||
|
const query = getQuery(event)
|
||||||
|
const width = query.width ? parseInt(query.width) : null
|
||||||
|
const height = query.height ? parseInt(query.height) : null
|
||||||
|
|
||||||
|
let imageBuffer = await fs.readFile(filePath)
|
||||||
|
|
||||||
|
// Bild verarbeiten falls Größe angegeben
|
||||||
|
if (width || height) {
|
||||||
|
const resizeOptions = {}
|
||||||
|
if (width && height) {
|
||||||
|
resizeOptions.width = width
|
||||||
|
resizeOptions.height = height
|
||||||
|
resizeOptions.fit = 'cover'
|
||||||
|
} else if (width) {
|
||||||
|
resizeOptions.width = width
|
||||||
|
resizeOptions.fit = 'inside'
|
||||||
|
resizeOptions.withoutEnlargement = true
|
||||||
|
} else if (height) {
|
||||||
|
resizeOptions.height = height
|
||||||
|
resizeOptions.fit = 'inside'
|
||||||
|
resizeOptions.withoutEnlargement = true
|
||||||
|
}
|
||||||
|
|
||||||
|
imageBuffer = await sharp(imageBuffer)
|
||||||
|
.rotate() // EXIF-Orientierung korrigieren
|
||||||
|
.resize(resizeOptions)
|
||||||
|
.toBuffer()
|
||||||
|
} else {
|
||||||
|
// Nur EXIF-Orientierung korrigieren
|
||||||
|
imageBuffer = await sharp(imageBuffer).rotate().toBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
setHeader(event, 'Content-Type', contentType)
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=31536000')
|
||||||
|
|
||||||
|
return imageBuffer
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden des Personenfotos:', error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Fehler beim Laden des Bildes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
127
server/api/personen/upload.post.js
Normal file
127
server/api/personen/upload.post.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import multer from 'multer'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { getUserFromToken, verifyToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
// Handle both dev and production paths
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONEN_DIR = getDataPath('personen')
|
||||||
|
|
||||||
|
// Multer-Konfiguration für Bild-Uploads
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(PERSONEN_DIR, { recursive: true })
|
||||||
|
cb(null, PERSONEN_DIR)
|
||||||
|
} catch (error) {
|
||||||
|
cb(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname)
|
||||||
|
const filename = `${randomUUID()}${ext}`
|
||||||
|
cb(null, filename)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
if (allowedMimes.includes(file.mimetype)) {
|
||||||
|
cb(null, true)
|
||||||
|
} else {
|
||||||
|
cb(new Error('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)'), false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024 // 10MB Limit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Ungültiges Token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer-Middleware für multipart/form-data
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
upload.single('image')(event.node.req, event.node.res, (err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = event.node.req.file
|
||||||
|
if (!file) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Keine Bilddatei hochgeladen'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bild mit sharp verarbeiten (EXIF-Orientierung korrigieren und optional resize)
|
||||||
|
const originalPath = file.path
|
||||||
|
const ext = path.extname(file.originalname)
|
||||||
|
const newFilename = `${randomUUID()}${ext}`
|
||||||
|
const newPath = path.join(PERSONEN_DIR, newFilename)
|
||||||
|
|
||||||
|
// Bild verarbeiten: EXIF-Orientierung korrigieren
|
||||||
|
await sharp(originalPath)
|
||||||
|
.rotate()
|
||||||
|
.toFile(newPath)
|
||||||
|
|
||||||
|
// Temporäre Datei löschen
|
||||||
|
await fs.unlink(originalPath).catch(() => {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Bild erfolgreich hochgeladen',
|
||||||
|
filename: newFilename
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Hochladen des Personenfotos:', error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Fehler beim Hochladen des Bildes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, migrateUserRoles } from '../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -29,6 +29,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
|
||||||
// Return user data (without password)
|
// Return user data (without password)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -37,7 +40,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
role: user.role
|
roles: roles,
|
||||||
|
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById, readUsers, writeUsers, verifyPassword, hashPassword } from '../utils/auth.js'
|
import { verifyToken, getUserById, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles } from '../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -80,6 +80,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
|
|
||||||
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Profil erfolgreich aktualisiert.',
|
message: 'Profil erfolgreich aktualisiert.',
|
||||||
@@ -88,7 +91,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
role: user.role
|
roles: roles,
|
||||||
|
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { deleteTermin } from '../utils/termine.js'
|
import { deleteTermin } from '../utils/termine.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can delete termine
|
// Only admin and vorstand can delete termine
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Löschen von Terminen.'
|
message: 'Keine Berechtigung zum Löschen von Terminen.'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { readTermine } from '../utils/termine.js'
|
import { readTermine } from '../utils/termine.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can manage termine
|
// Only admin and vorstand can manage termine
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Verwalten von Terminen.'
|
message: 'Keine Berechtigung zum Verwalten von Terminen.'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveTermin } from '../utils/termine.js'
|
import { saveTermin } from '../utils/termine.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getUserById(decoded.id)
|
const user = await getUserById(decoded.id)
|
||||||
|
|
||||||
// Only admin and vorstand can create termine
|
// Only admin and vorstand can create termine
|
||||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: 'Keine Berechtigung zum Erstellen von Terminen.'
|
message: 'Keine Berechtigung zum Erstellen von Terminen.'
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
"name": "Torsten Schulz",
|
"name": "Torsten Schulz",
|
||||||
"lizenz": "C-Trainer",
|
"lizenz": "C-Trainer",
|
||||||
"schwerpunkt": "Nachwuchsförderung",
|
"schwerpunkt": "Nachwuchsförderung",
|
||||||
"zusatz": "Erwachsenen bei Wunsch zur Verfügung"
|
"zusatz": "Erwachsenen bei Wunsch zur Verfügung",
|
||||||
|
"imageFilename": "8f79a5b9-bfba-43c4-9ab8-81192337bd8f.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2",
|
"id": "2",
|
||||||
@@ -99,7 +100,8 @@
|
|||||||
"plz": "60437",
|
"plz": "60437",
|
||||||
"ort": "Frankfurt",
|
"ort": "Frankfurt",
|
||||||
"telefon": "06101-9953015",
|
"telefon": "06101-9953015",
|
||||||
"email": "rogerdichmann@gmx.de"
|
"email": "rogerdichmann@gmx.de",
|
||||||
|
"imageFilename": "c24ef84d-2ae4-4edc-be01-063d9917da04.png"
|
||||||
},
|
},
|
||||||
"stellvertreter": {
|
"stellvertreter": {
|
||||||
"vorname": "Jürgen",
|
"vorname": "Jürgen",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
j1MPucV7uLGcNrRns92uU3f+fTt35Vpw7ImrahWaPQOAxGPJP0zZq6VOYYjuhvGlmE708rxZsPog/7PvuKc5YOwM5H9Bzhwf4HZFj98JrVU7gCkS5bm39NB1MrDS1yPblAurGFRrL28mi3d3Py+02cbV+YccQEw678jHqt6tazRfz1z005S5pYNAGf8GfJqAhtR4IA9ZTolszBiGA71gb33/RlwyZqUpnA7IEr1tlG7t21ueXcRNH+N2REgPYBrwmOkACGn6efdJpWoyglFLUOzt/uheXlrrprzJaUta3CZSPLC4JIHDHGEWgjwvAs14eDsfJbuaDegUAIpUrkGEsicPXIwj5gXrEc8FnEZSQISnrmj+jkYv86VZ8fXf8rmgSTjW5F8+tA5lSlJompb7wRQNmFLzLehdiatJtwHh1zhjfHBVG3VKKYgLppG8n0/LMc8BGKtb7xvIFshjnuTnbhbe5C7ocnefcOUVkVXhqXnbLAcmLQPn8ZjkJC0Vk9I5bTbRQr/1X0gsTPlkbtnLwtWF8puRPlx2eFimt3ZvzjTh+BxGagGM0wmTSqNh51WvbC10oPUyjCrL/tQJ2essSkufZ8KSVrnC3Tum/xATaL4fei/DFiYxoS9HqXaf1GvreiScbIPP61wgrjBSpuQmiDQfsVprdT9l+A7diF6LJXlcEpeWvSWq5E0h39QgoqYrg6uwd6Jilg6RSMcpnNWozRzqTLhTJ9ZCdaH3TLcX3qX5M9zLV/gVmy8m4gXaaiMo2WSjuryXNapT/lGIhZlFojhz0BmnId/SYUTvh2ds0WdYHfXEP1HGVcFhgLibI5tz4B9zJma/nReDle/23lhTc5coh3Gi2bvxC2CrkXUwiPK+SYW+yaBiZ5c7rGAhXtZebRJjGGZD6IA5dT6fYeQF7tVbYs3KcKWEVHVB4XomcsTd1neHPtnCVMj3d3aJ94ihJ5C5CJy3qHd60ovxcbsbqL7sK+jY84muHB2AKkSm5TdM95aGxovGpaPvXiHYMbSf+pbPKdKzS16NJ/8RnNweUYYC2Naabw+GYxrlxqS31j5ZXnLVsms1GQ9yMcJyrNmCbzu6aqM0j9HBq5Wi5Lo5y04EdIqqIzBCrNlmRllWK2Sa78j6nLLiP1W4zzF/51W/Oqp++zd2ns/TS1+JUDnHYqRUaBFHLB1lz854ODv8T9Shu85ExY/RWkCZan+P0bi24x+6hYVR0sPpgvyBjka4cY7NXnpU9nSnJpK1ve4nAA2o0vdv9OpO3CIgv37vucFIXOCJRfcWBL5rrFYE99Kaf+tTSWEwBoIm71txBLhy9yVElefY4G9C1S3Wl/cyCmgz
|
WDoadIosSFzqf+ASCWH+PIYZmZ+aFg+GkJYAEReZOaZ8FrVlC+e2kx69+XYTMf3vRUzem5xrzc4RxX6s434EyJV2vW+LqPzVFTtyFZ9Zc26kQ7svj024kAgF89Pc3E/Bq+XRvH1oHX0puAY3/3aaiI/uQmIZ/sk1E9PHvern/zyZvwx53lThl3Ac6Z6k6qb5ogYIPNSPUn2U+bkgdW/jxxwRff7UuenVW+xdeR2YsrHBIWa+9UPan2xLNo+zHAv7/WI5XPmo4esooOTOU12MY4hD6+ELmzcgbc/5F6V4TQLR+X8WDdHJ2OfH/8J/nQ2ba9x8NuKuB/Obbd9ChDDNLlrP9P3vHZV99VdMAC9WsgAVRnM5hT1ZneXe1iuOq0Zblnk7f0MwXJGpwt1h8BKAI1MAcy7GGZSC24Ca2mOUWZ7UaQp3RmUkoqRGNANN+wGqBtOnYO5BS1qbn/sedApGfTX1PYi5WyzLr7RB0ZGBtcz567rS0E1O1fApe9QKTuWvAXdFsLz8Ssh9BerWsPTgifgbHPQJJqYQy/CiG82i9tOYA5N1udTuTFWgb23tuJATUpY75WW7kehNKGRpBkKfUo8C9RPcNCRqzg6H3iMkZNKEFYx+y1fACkUw0lUES5f6rrj8QNgCSZieQmBh8ETarAyg8jMreQI8hz1cJZcoNeBI2b3tjBjTMhaVwx45M8/r
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ==
|
CyRDzIKS5Ou7WW0Ri1r4G7yGBFv1MwyJUsdJYNUI2gx7KJ5Mr4d8JBe4YQ+vQlpFw/ZEhrBLXjsKwbEIMlmO/xZWln9TsE/1s9rwCd9WoCWrXOlSqQc6kWP6xJuoy0tXRBCGfEPqoIg/x/G/QsN0kIdnWPETOqOd9p9nc/OsmbhXHTGIUa2KKDNsk5JMJVRI1IUt8CzdpXQUQpbSBA8AgBV7sUiePWXlbqxfoWC7leV8oRWcgTz1Y0hKVB/yczjPUQP7hEI7GZ9O/2fysrTRPa5JtmwQ4CbfXe1wWANmxrIsUf1n/+yogcVfkG+Ld6YjhCnh1hmDQFEh7RkSB8J9uknvlrk/uXsnwRP55jBeum0ujsOaxisagJ1oniCVg27r2+fx0qiAIQDv5pVDp+EWkDMo4Wkw4qis6HwA46hy4ex22O4As550xhnomHq/Rtk6mO20Srlt+7dbUcopvVZn/ekXzL8ovzYFHA978B63m2Vt6m7wYdGduSjUChzXXcRUJwF2JKnOiSym2/zQ9EJi8UFBMgSaXAku9PakLUWI13VInKItLCX/Ib9ADWMLiViDmzW3dYHKxENdBeo8tD4vGExEY7+5x+Ari6zIGhcoYt8MRyGMGdrqSYTLCnlRnzgeHqN2JTyiYns8fCNUuV7aa31x5GgzD/Bpc1JJG+o6DYAva1GBLaaCTLTpuuDNC6V32cJECjzQaQKm8hhIg9OWjpApxhvx/0aiVs2Yne63Ot8183YAdfpX6QCD2F89hqQi6LjBxzC8vYi+2MWTdw4ZdkIRhrROe0/gxOWvecmrpyM=
|
||||||
@@ -4,6 +4,27 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
|
|
||||||
|
// Export migrateUserRoles für Verwendung in anderen Modulen
|
||||||
|
export function migrateUserRoles(user) {
|
||||||
|
if (!user) return user
|
||||||
|
|
||||||
|
// Wenn bereits roles Array vorhanden, nichts tun
|
||||||
|
if (Array.isArray(user.roles)) {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn role vorhanden, zu roles Array konvertieren
|
||||||
|
if (user.role) {
|
||||||
|
user.roles = [user.role]
|
||||||
|
delete user.role
|
||||||
|
} else {
|
||||||
|
// Fallback: Standard-Rolle
|
||||||
|
user.roles = ['mitglied']
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production'
|
const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
@@ -50,17 +71,17 @@ export async function readUsers() {
|
|||||||
const data = await fs.readFile(USERS_FILE, 'utf-8')
|
const data = await fs.readFile(USERS_FILE, 'utf-8')
|
||||||
|
|
||||||
const encrypted = isEncrypted(data)
|
const encrypted = isEncrypted(data)
|
||||||
|
let users = []
|
||||||
|
|
||||||
if (encrypted) {
|
if (encrypted) {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
try {
|
try {
|
||||||
return decryptObject(data, encryptionKey)
|
users = decryptObject(data, encryptionKey)
|
||||||
} catch (decryptError) {
|
} catch (decryptError) {
|
||||||
console.error('Fehler beim Entschlüsseln der Benutzerdaten:', decryptError)
|
console.error('Fehler beim Entschlüsseln der Benutzerdaten:', decryptError)
|
||||||
try {
|
try {
|
||||||
const plainData = JSON.parse(data)
|
users = JSON.parse(data)
|
||||||
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
||||||
return plainData
|
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
|
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
|
||||||
return []
|
return []
|
||||||
@@ -68,14 +89,30 @@ export async function readUsers() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Plain JSON - migrate to encrypted format
|
// Plain JSON - migrate to encrypted format
|
||||||
const users = JSON.parse(data)
|
users = JSON.parse(data)
|
||||||
console.log('Migriere unverschlüsselte Benutzerdaten zu verschlüsselter Speicherung...')
|
console.log('Migriere unverschlüsselte Benutzerdaten zu verschlüsselter Speicherung...')
|
||||||
|
|
||||||
// Write back encrypted
|
|
||||||
await writeUsers(users)
|
|
||||||
|
|
||||||
return users
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migriere Rollen von role zu roles Array
|
||||||
|
let needsMigration = false
|
||||||
|
users = users.map(user => {
|
||||||
|
const migrated = migrateUserRoles(user)
|
||||||
|
if (!Array.isArray(user.roles) && user.role) {
|
||||||
|
needsMigration = true
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wenn Migration nötig war, speichere zurück
|
||||||
|
if (needsMigration) {
|
||||||
|
console.log('Migriere Rollen von role zu roles Array...')
|
||||||
|
await writeUsers(users)
|
||||||
|
} else if (!encrypted) {
|
||||||
|
// Write back encrypted wenn noch nicht verschlüsselt
|
||||||
|
await writeUsers(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
return []
|
return []
|
||||||
@@ -98,21 +135,65 @@ export async function writeUsers(users) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read sessions from file
|
// Prüft ob Sessions-Daten verschlüsselt sind
|
||||||
|
function isSessionsEncrypted(data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.trim())
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read sessions from file (with encryption support)
|
||||||
export async function readSessions() {
|
export async function readSessions() {
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(SESSIONS_FILE, 'utf-8')
|
const data = await fs.readFile(SESSIONS_FILE, 'utf-8')
|
||||||
return JSON.parse(data)
|
const encrypted = isSessionsEncrypted(data)
|
||||||
|
|
||||||
|
if (encrypted) {
|
||||||
|
const encryptionKey = getEncryptionKey()
|
||||||
|
try {
|
||||||
|
return decryptObject(data, encryptionKey)
|
||||||
|
} catch (decryptError) {
|
||||||
|
console.error('Fehler beim Entschlüsseln der Sessions:', decryptError)
|
||||||
|
try {
|
||||||
|
const plainData = JSON.parse(data)
|
||||||
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
||||||
|
return plainData
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Konnte Sessions weder entschlüsseln noch als JSON lesen')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain JSON - migriere zu verschlüsselter Speicherung
|
||||||
|
const sessions = JSON.parse(data)
|
||||||
|
console.log('Migriere unverschlüsselte Sessions zu verschlüsselter Speicherung...')
|
||||||
|
await writeSessions(sessions)
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
console.error('Fehler beim Lesen der Sessions:', error)
|
console.error('Fehler beim Lesen der Sessions:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write sessions to file
|
// Write sessions to file (always encrypted)
|
||||||
export async function writeSessions(sessions) {
|
export async function writeSessions(sessions) {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(SESSIONS_FILE, JSON.stringify(sessions, null, 2), 'utf-8')
|
const encryptionKey = getEncryptionKey()
|
||||||
|
const encryptedData = encryptObject(sessions, encryptionKey)
|
||||||
|
await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8')
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Sessions:', error)
|
console.error('Fehler beim Schreiben der Sessions:', error)
|
||||||
@@ -133,11 +214,15 @@ export async function verifyPassword(password, hash) {
|
|||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
export function generateToken(user) {
|
export function generateToken(user) {
|
||||||
|
// Stelle sicher, dass Rollen migriert sind
|
||||||
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role
|
roles: roles
|
||||||
},
|
},
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '7d' }
|
||||||
@@ -156,13 +241,37 @@ export function verifyToken(token) {
|
|||||||
// Get user by ID
|
// Get user by ID
|
||||||
export async function getUserById(id) {
|
export async function getUserById(id) {
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
return users.find(u => u.id === id)
|
const user = users.find(u => u.id === id)
|
||||||
|
return user ? migrateUserRoles(user) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user by email
|
// Get user by email
|
||||||
export async function getUserByEmail(email) {
|
export async function getUserByEmail(email) {
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
return users.find(u => u.email === email)
|
const user = users.find(u => u.email === email)
|
||||||
|
return user ? migrateUserRoles(user) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Prüft ob Benutzer eine bestimmte Rolle hat
|
||||||
|
export function hasRole(user, role) {
|
||||||
|
if (!user) return false
|
||||||
|
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
return roles.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft ob Benutzer eine der angegebenen Rollen hat
|
||||||
|
export function hasAnyRole(user, ...roles) {
|
||||||
|
if (!user) return false
|
||||||
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
return roles.some(role => userRoles.includes(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft ob Benutzer alle angegebenen Rollen hat
|
||||||
|
export function hasAllRoles(user, ...roles) {
|
||||||
|
if (!user) return false
|
||||||
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
return roles.every(role => userRoles.includes(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from token
|
// Get user from token
|
||||||
@@ -171,7 +280,14 @@ export async function getUserFromToken(token) {
|
|||||||
if (!decoded) return null
|
if (!decoded) return null
|
||||||
|
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
return users.find(u => u.id === decoded.id)
|
const user = users.find(u => u.id === decoded.id)
|
||||||
|
|
||||||
|
// Migriere Rollen beim Laden
|
||||||
|
if (user) {
|
||||||
|
migrateUserRoles(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
|
|||||||
287
server/utils/newsletter.js
Normal file
287
server/utils/newsletter.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { readMembers } from './members.js'
|
||||||
|
import { readUsers } from './auth.js'
|
||||||
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWSLETTER_SUBSCRIBERS_FILE = getDataPath('newsletter-subscribers.json')
|
||||||
|
|
||||||
|
function getEncryptionKey() {
|
||||||
|
return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft ob Daten verschlüsselt sind
|
||||||
|
function isEncrypted(data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.trim())
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSubscribers() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(NEWSLETTER_SUBSCRIBERS_FILE, 'utf-8')
|
||||||
|
const encrypted = isEncrypted(data)
|
||||||
|
|
||||||
|
if (encrypted) {
|
||||||
|
const encryptionKey = getEncryptionKey()
|
||||||
|
try {
|
||||||
|
return decryptObject(data, encryptionKey)
|
||||||
|
} catch (decryptError) {
|
||||||
|
console.error('Fehler beim Entschlüsseln der Newsletter-Abonnenten:', decryptError)
|
||||||
|
try {
|
||||||
|
const plainData = JSON.parse(data)
|
||||||
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
||||||
|
return plainData
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Konnte Newsletter-Abonnenten weder entschlüsseln noch als JSON lesen')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain JSON - migriere zu verschlüsselter Speicherung
|
||||||
|
const subscribers = JSON.parse(data)
|
||||||
|
console.log('Migriere unverschlüsselte Newsletter-Abonnenten zu verschlüsselter Speicherung...')
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
return subscribers
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSubscribers(subscribers) {
|
||||||
|
try {
|
||||||
|
const encryptionKey = getEncryptionKey()
|
||||||
|
const encryptedData = encryptObject(subscribers, encryptionKey)
|
||||||
|
await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnet Alter aus Geburtsdatum
|
||||||
|
function calculateAge(geburtsdatum) {
|
||||||
|
if (!geburtsdatum) return null
|
||||||
|
try {
|
||||||
|
const birthDate = new Date(geburtsdatum)
|
||||||
|
const today = new Date()
|
||||||
|
let age = today.getFullYear() - birthDate.getFullYear()
|
||||||
|
const monthDiff = today.getMonth() - birthDate.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
return age
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtert den Admin-User aus Empfängerliste heraus
|
||||||
|
function filterAdminUser(recipients) {
|
||||||
|
return recipients.filter(r => {
|
||||||
|
const email = (r.email || '').toLowerCase().trim()
|
||||||
|
return email !== 'admin@harheimertc.de'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtert Mitglieder nach Zielgruppe
|
||||||
|
export async function getRecipientsByGroup(targetGroup) {
|
||||||
|
const members = await readMembers()
|
||||||
|
const users = await readUsers()
|
||||||
|
|
||||||
|
let recipients = []
|
||||||
|
|
||||||
|
switch (targetGroup) {
|
||||||
|
case 'alle':
|
||||||
|
// Alle Mitglieder mit E-Mail
|
||||||
|
recipients = members
|
||||||
|
.filter(m => m.email && m.email.trim() !== '')
|
||||||
|
.map(m => ({
|
||||||
|
email: m.email,
|
||||||
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
|
}))
|
||||||
|
// Auch alle aktiven Benutzer hinzufügen
|
||||||
|
users
|
||||||
|
.filter(u => u.active && u.email)
|
||||||
|
.forEach(u => {
|
||||||
|
if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) {
|
||||||
|
recipients.push({
|
||||||
|
email: u.email,
|
||||||
|
name: u.name || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'erwachsene':
|
||||||
|
// Mitglieder über 18 Jahre
|
||||||
|
recipients = members
|
||||||
|
.filter(m => {
|
||||||
|
if (!m.email || !m.email.trim()) return false
|
||||||
|
const age = calculateAge(m.geburtsdatum)
|
||||||
|
return age !== null && age >= 18
|
||||||
|
})
|
||||||
|
.map(m => ({
|
||||||
|
email: m.email.trim(),
|
||||||
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
|
}))
|
||||||
|
// Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum)
|
||||||
|
users
|
||||||
|
.filter(u => u.active && u.email && u.email.trim())
|
||||||
|
.forEach(u => {
|
||||||
|
// Prüfe ob bereits vorhanden
|
||||||
|
if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase().trim())) {
|
||||||
|
recipients.push({
|
||||||
|
email: u.email.trim(),
|
||||||
|
name: u.name || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'nachwuchs':
|
||||||
|
// Mitglieder unter 18 Jahre
|
||||||
|
recipients = members
|
||||||
|
.filter(m => {
|
||||||
|
if (!m.email || !m.email.trim()) return false
|
||||||
|
const age = calculateAge(m.geburtsdatum)
|
||||||
|
return age !== null && age < 18
|
||||||
|
})
|
||||||
|
.map(m => ({
|
||||||
|
email: m.email,
|
||||||
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'mannschaftsspieler':
|
||||||
|
// Mitglieder die in einer Mannschaft spielen
|
||||||
|
recipients = members
|
||||||
|
.filter(m => {
|
||||||
|
if (!m.email || !m.email.trim()) return false
|
||||||
|
// Prüfe ob als Mannschaftsspieler markiert
|
||||||
|
if (m.isMannschaftsspieler === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Fallback: Prüfe ob in notes etwas über Mannschaft steht (für Rückwärtskompatibilität)
|
||||||
|
const notes = (m.notes || '').toLowerCase()
|
||||||
|
return notes.includes('mannschaft') || notes.includes('spieler')
|
||||||
|
})
|
||||||
|
.map(m => ({
|
||||||
|
email: m.email,
|
||||||
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'vorstand':
|
||||||
|
// Nur Vorstand (aus users.json)
|
||||||
|
recipients = users
|
||||||
|
.filter(u => {
|
||||||
|
if (!u.active || !u.email) return false
|
||||||
|
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
|
||||||
|
return roles.includes('admin') || roles.includes('vorstand')
|
||||||
|
})
|
||||||
|
.map(u => ({
|
||||||
|
email: u.email,
|
||||||
|
name: u.name || ''
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
recipients = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-User herausfiltern
|
||||||
|
return filterAdminUser(recipients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet)
|
||||||
|
export async function getNewsletterSubscribers(internalOnly = false, groupId = null) {
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
|
||||||
|
let confirmedSubscribers = subscribers.filter(s => {
|
||||||
|
if (!s.confirmed || s.unsubscribedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn groupId angegeben ist, prüfe ob Subscriber für diese Gruppe angemeldet ist
|
||||||
|
if (groupId) {
|
||||||
|
// Stelle sicher, dass groupIds existiert (für Rückwärtskompatibilität)
|
||||||
|
if (!s.groupIds || !Array.isArray(s.groupIds)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.groupIds.includes(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn keine groupId angegeben, prüfe ob Subscriber für mindestens eine Gruppe angemeldet ist
|
||||||
|
// (für Rückwärtskompatibilität: wenn keine groupIds vorhanden, als abonniert behandeln)
|
||||||
|
if (s.groupIds && Array.isArray(s.groupIds)) {
|
||||||
|
return s.groupIds.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rückwärtskompatibilität: alte Subscriber ohne groupIds werden als abonniert behandelt
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (internalOnly) {
|
||||||
|
// Nur interne Abonnenten (die auch Mitglieder sind)
|
||||||
|
const members = await readMembers()
|
||||||
|
const memberEmails = new Set(
|
||||||
|
members
|
||||||
|
.filter(m => m.email)
|
||||||
|
.map(m => m.email.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmedSubscribers = confirmedSubscribers.filter(s =>
|
||||||
|
memberEmails.has(s.email.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = confirmedSubscribers.map(s => ({
|
||||||
|
email: s.email,
|
||||||
|
name: s.name || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Admin-User herausfiltern
|
||||||
|
return filterAdminUser(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generiert Abmelde-Token für Abonnenten
|
||||||
|
export async function generateUnsubscribeToken(email) {
|
||||||
|
const subscribers = await readSubscribers()
|
||||||
|
const subscriber = subscribers.find(s => s.email.toLowerCase() === email.toLowerCase())
|
||||||
|
|
||||||
|
if (!subscriber) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscriber.unsubscribeToken) {
|
||||||
|
subscriber.unsubscribeToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
await writeSubscribers(subscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriber.unsubscribeToken
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,12 +4,22 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
user: null,
|
user: null,
|
||||||
role: null
|
roles: [],
|
||||||
|
role: null // Rückwärtskompatibilität: erste Rolle
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
isAdmin: (state) => {
|
isAdmin: (state) => {
|
||||||
return state.role === 'admin' || state.role === 'vorstand'
|
return state.roles.includes('admin') || state.roles.includes('vorstand')
|
||||||
|
},
|
||||||
|
isNewsletter: (state) => {
|
||||||
|
return state.roles.includes('newsletter')
|
||||||
|
},
|
||||||
|
hasRole: (state) => {
|
||||||
|
return (role) => state.roles.includes(role)
|
||||||
|
},
|
||||||
|
hasAnyRole: (state) => {
|
||||||
|
return (...roles) => roles.some(role => state.roles.includes(role))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -19,11 +29,13 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
const response = await $fetch('/api/auth/status')
|
const response = await $fetch('/api/auth/status')
|
||||||
this.isLoggedIn = response.isLoggedIn
|
this.isLoggedIn = response.isLoggedIn
|
||||||
this.user = response.user
|
this.user = response.user
|
||||||
this.role = response.role
|
this.roles = response.roles || (response.role ? [response.role] : [])
|
||||||
|
this.role = response.role || (this.roles.length > 0 ? this.roles[0] : null) // Rückwärtskompatibilität
|
||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.isLoggedIn = false
|
this.isLoggedIn = false
|
||||||
this.user = null
|
this.user = null
|
||||||
|
this.roles = []
|
||||||
this.role = null
|
this.role = null
|
||||||
return { isLoggedIn: false }
|
return { isLoggedIn: false }
|
||||||
}
|
}
|
||||||
@@ -47,6 +59,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
this.isLoggedIn = false
|
this.isLoggedIn = false
|
||||||
this.user = null
|
this.user = null
|
||||||
|
this.roles = []
|
||||||
this.role = null
|
this.role = null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout fehlgeschlagen:', error)
|
console.error('Logout fehlgeschlagen:', error)
|
||||||
|
|||||||
Reference in New Issue
Block a user