Compare commits

..

10 Commits

Author SHA1 Message Date
Torsten Schulz (local)
7e8d693832 Refactor training time input fields in Einstellungen component to improve layout and usability. Add optional information field for training times and ensure proper handling of group data. Update rendering logic in Training component to display additional information if provided. 2025-12-19 10:23:58 +01:00
Torsten Schulz (local)
6b24ac0071 Implement toggle functionality for Mannschaftsspieler status in Mitgliederbereich. Add button for editing status and update local state upon toggling. Enhance API response handling to include isMannschaftsspieler attribute for user data retrieval. 2025-12-19 10:14:41 +01:00
Torsten Schulz (local)
5a85c3d31a Remove debug console logs from MannschaftenUebersicht, TermineVorschau, spielplaene, and filterData components to clean up code and improve performance. 2025-12-19 10:06:01 +01:00
Torsten Schulz (local)
cbe02a6caf Enhance newsletter management by adding role-based access control for group creation. Introduce computed property to determine if the user can create groups based on their roles, improving functionality and user experience. 2025-12-19 10:03:21 +01:00
Torsten Schulz (local)
b33773e214 Update DATENSCHUTZ_UEBERSICHT.md to enhance data protection overview with a comprehensive table of contents, detailed descriptions of encrypted and non-encrypted data, and security policies. Include sections on encryption technology, authentication, authorization, API access control, roles and permissions, session management, environment variables, file system structure, external dependencies, and security guidelines. 2025-12-19 09:57:56 +01:00
Torsten Schulz (local)
435e28fd55 Update dependencies to include TinyMCE and Quill, enhance Navigation component with a new Newsletter submenu, and implement role-based access control for CMS features. Refactor user role handling to support multiple roles and improve user management functionality across various API endpoints. 2025-12-19 09:51:28 +01:00
Torsten Schulz (local)
baf6c59c0d Enhance Vereinsmeisterschaften and Vorstand pages with image support for players and board members. Implement lightbox functionality for player images in Vereinsmeisterschaften. Update CSV handling to include image filenames for better data management. Refactor components to utilize PersonCard for board members, improving code readability and maintainability. 2025-12-18 13:37:03 +01:00
Torsten Schulz (local)
a004ffba9b Implement checks for existing encryption with the new key in re-encryption scripts. Add functionality to skip re-encryption if data is already encrypted, enhancing efficiency and preventing redundant operations. 2025-12-18 13:12:02 +01:00
Torsten Schulz (local)
10499e0249 Enhance set-admin-password script to handle decryption errors by prompting for recreation of users.json file. Implement backup creation before overwriting and update README with usage instructions for the new functionality. 2025-12-18 12:45:53 +01:00
Torsten Schulz (local)
4b017453b2 Update AUTH_README.md to clarify admin password setup process and provide usage instructions for the set-admin-password script. Change file permissions for deploy.sh, production-setup.sh, and fetch-template.sh to make them executable. 2025-12-18 12:39:22 +01:00
94 changed files with 6918 additions and 537 deletions

View File

@@ -7,9 +7,23 @@
⚠️ **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
import bcrypt from 'bcryptjs'

510
DATENSCHUTZ_UEBERSICHT.md Normal file
View 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

View 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
View 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>

View File

@@ -91,23 +91,18 @@ const mannschaften = ref([])
const loadMannschaften = async () => {
try {
console.log('Lade Mannschaften...')
const response = await fetch('/data/mannschaften.csv')
console.log('Response:', response)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const csv = await response.text()
console.log('CSV Text:', csv)
// Vereinfachter CSV-Parser
const lines = csv.split('\n').filter(line => line.trim() !== '')
console.log('CSV Lines:', lines)
if (lines.length < 2) {
console.log('Keine Datenzeilen gefunden')
return
}
@@ -132,7 +127,6 @@ const loadMannschaften = async () => {
values.push(current.trim())
if (values.length < 10) {
console.log(`Zeile ${index + 2} hat zu wenige Werte:`, values)
return null
}
@@ -149,14 +143,8 @@ const loadMannschaften = async () => {
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
}).filter(mannschaft => mannschaft !== null)
console.log('Alle geparsten Mannschaften:', mannschaften.value)
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
}

View File

@@ -53,11 +53,17 @@
Termine
</NuxtLink>
<NuxtLink v-if="hasGalleryImages" to="/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"
active-class="text-white bg-primary-600">
Galerie
</NuxtLink>
<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"
active-class="text-white bg-primary-600">
Galerie
</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')"
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 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 -->
<template v-if="currentSubmenu === 'verein'">
<NuxtLink to="/verein/ueber-uns"
@@ -188,6 +207,14 @@
active-class="text-white bg-primary-600">
API-Dokumentation
</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">
<div class="h-3 w-px bg-primary-700" />
<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">
Galerie
</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>
@@ -395,10 +430,18 @@
Termine
</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">
Galerie
</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 -->
<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">
Mein Profil
</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">
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink to="/cms" @click="isMobileMenuOpen = false"
@@ -509,6 +559,7 @@ const showCmsDropdown = ref(false)
// Reactive auth state from store
const isLoggedIn = computed(() => authStore.isLoggedIn)
const isAdmin = computed(() => authStore.isAdmin)
const canAccessNewsletter = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'newsletter'))
// Automatisches Setzen des Submenus basierend auf der Route
const currentSubmenu = computed(() => {
@@ -526,6 +577,9 @@ const currentSubmenu = computed(() => {
if (path.startsWith('/mitgliederbereich') || path.startsWith('/cms')) {
return 'intern'
}
if (path.startsWith('/newsletter')) {
return 'newsletter'
}
return null
})
@@ -613,7 +667,9 @@ const toggleSubmenu = (menu) => {
// Wenn nicht, zur Hauptseite navigieren
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')
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
navigateTo('/mannschaften')

35
components/PersonCard.vue Normal file
View 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>

View 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>

View File

@@ -49,7 +49,6 @@ const termine = ref([])
const naechsteTermine = computed(() => {
const heute = new Date()
console.log('Heute ist:', heute.toISOString().split('T')[0])
const kommende = termine.value
.filter(t => {
@@ -61,9 +60,7 @@ const naechsteTermine = computed(() => {
} else {
terminDatum = new Date(t.datum)
}
const istKommend = terminDatum >= heute
console.log(`Termin ${t.titel} (${t.datum}): ${istKommend ? 'KOMMEND' : 'VERSTRICHEN'}`)
return istKommend
return terminDatum >= heute
})
.sort((a, b) => {
const da = new Date(`${a.datum}${a.uhrzeit ? 'T' + a.uhrzeit + ':00' : ''}`)
@@ -71,7 +68,6 @@ const naechsteTermine = computed(() => {
return da - db
})
console.log('Kommende Termine:', kommende)
return kommende
})

0
deploy.sh Normal file → Executable file
View File

View File

@@ -17,9 +17,20 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
// Check role for CMS
if (to.path.startsWith('/cms')) {
const isAdmin = auth.value.role === 'admin' || auth.value.role === 'vorstand'
if (!isAdmin) {
return navigateTo('/mitgliederbereich')
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
// 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) {

90
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@pinia/nuxt": "^0.11.2",
"@tinymce/tinymce-vue": "^6.3.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -18,7 +19,9 @@
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pinia": "^3.0.3",
"quill": "^2.0.3",
"sharp": "^0.34.5",
"tinymce": "^8.3.1",
"vue": "^3.5.22"
},
"devDependencies": {
@@ -3827,6 +3830,21 @@
"integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==",
"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": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -6368,6 +6386,12 @@
"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": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -6425,6 +6449,12 @@
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
"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": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -7970,6 +8000,18 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"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": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -7994,6 +8036,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"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": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@@ -9094,6 +9143,12 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
@@ -10118,6 +10173,35 @@
],
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
@@ -11665,6 +11749,12 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"@pinia/nuxt": "^0.11.2",
"@tinymce/tinymce-vue": "^6.3.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -24,7 +25,9 @@
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pinia": "^3.0.3",
"quill": "^2.0.3",
"sharp": "^0.34.5",
"tinymce": "^8.3.1",
"vue": "^3.5.22"
},
"devDependencies": {

View File

@@ -46,6 +46,7 @@
<option value="mitglied">Mitglied</option>
<option value="vorstand">Vorstand</option>
<option value="admin">Administrator</option>
<option value="newsletter">Newsletter</option>
</select>
<!-- Approve Button -->
@@ -112,20 +113,27 @@
<div class="text-sm text-gray-600">{{ user.phone || '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<select
v-model="user.role"
@change="updateUserRole(user)"
class="px-3 py-1 border border-gray-300 rounded text-sm"
:class="{
'bg-red-50 border-red-300': user.role === 'admin',
'bg-blue-50 border-blue-300': user.role === 'vorstand',
'bg-gray-50 border-gray-300': user.role === 'mitglied'
}"
<div class="flex flex-wrap gap-1">
<span
v-for="role in (user.roles || (user.role ? [user.role] : ['mitglied']))"
:key="role"
class="px-2 py-1 text-xs font-medium rounded"
:class="{
'bg-red-100 text-red-800': role === 'admin',
'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>
<option value="vorstand">Vorstand</option>
<option value="admin">Administrator</option>
</select>
Bearbeiten
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">
@@ -162,6 +170,79 @@
</p>
</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>
</template>
@@ -173,11 +254,17 @@ const allUsers = ref([])
const currentUserId = ref(null)
const successMessage = ref('')
const errorMessage = ref('')
const showRoleModal = ref(false)
const editingUser = ref(null)
const selectedRoles = ref([])
const pendingUsers = computed(() => {
return allUsers.value
.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(() => {
@@ -210,7 +297,7 @@ const approveUser = async (user) => {
method: 'POST',
body: {
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) => {
window.showConfirmModal('Registrierung ablehnen', `Möchten Sie die Registrierung von ${user.name} wirklich ablehnen?`, async () => {
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) => {
window.showConfirmModal('Benutzer deaktivieren', `Möchten Sie ${user.name} wirklich deaktivieren?`, async () => {

View File

@@ -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"
/>
</div>
<div class="sm:col-span-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Gruppe</label>
<div class="flex space-x-2">
<input
v-model="zeit.gruppe"
type="text"
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>
<input
v-model="zeit.gruppe"
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"
/>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Zusätzliche Information (optional)</label>
<div class="flex space-x-2">
<input
v-model="zeit.info"
type="text"
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>
@@ -295,6 +304,12 @@
</button>
</div>
</div>
<div class="sm:col-span-2">
<ImageUpload
v-model="trainer.imageFilename"
label="Foto"
/>
</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"
/>
</div>
<div class="sm:col-span-2">
<ImageUpload
v-model="position.imageFilename"
label="Foto"
/>
</div>
</div>
</div>
</div>
@@ -572,7 +593,8 @@ const addTrainingTime = () => {
tag: 'Montag',
von: '19:00',
bis: '22:00',
gruppe: `Gruppe ${naechsteGruppeNummer}`
gruppe: `Gruppe ${naechsteGruppeNummer}`,
info: ''
})
}

View File

@@ -138,7 +138,7 @@
<!-- Benutzerverwaltung (nur für Admin) -->
<NuxtLink
v-if="authStore.role === 'admin'"
v-if="authStore.hasRole('admin')"
to="/cms/benutzer"
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
View 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>

View File

@@ -325,16 +325,6 @@ const processFile = async (file) => {
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
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
currentFile.value = {
name: file.name,
@@ -550,7 +540,6 @@ const loadExistingData = async () => {
}
}
} catch (error) {
console.log('Keine bestehende Spielplan-Datei gefunden')
}
}
</script>

View File

@@ -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">
{{ result.platz }}
</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 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 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"
@click.self="closeModal"
>
<div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
{{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
</h3>
<div class="bg-white rounded-lg max-w-md 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">
{{ 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>
<label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label>
<input
@@ -236,6 +258,20 @@
/>
</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>
<label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung (optional)</label>
<textarea
@@ -245,22 +281,25 @@
></textarea>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
</button>
</div>
</form>
</form>
</div>
<div class="p-6 border-t border-gray-200 flex-shrink-0 flex justify-end space-x-3">
<button
type="button"
@click="closeModal"
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="result-form"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
</button>
</div>
</div>
</div>
@@ -330,7 +369,9 @@ const formData = ref({
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
})
const loadResults = async () => {
@@ -370,6 +411,7 @@ const loadResults = async () => {
}
values.push(current.trim())
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
if (values.length < 6) return null
return {
@@ -378,7 +420,9 @@ const loadResults = async () => {
platz: values[2].trim(),
spieler1: values[3].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)
} catch (error) {
@@ -445,7 +489,9 @@ const addNewResult = () => {
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}
showModal.value = true
}
@@ -461,7 +507,9 @@ const addResultForYear = (jahr) => {
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}
showModal.value = true
}
@@ -477,7 +525,9 @@ const addResultForKategorie = (jahr, kategorie) => {
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}
showModal.value = true
}
@@ -493,7 +543,9 @@ const editResult = (result, jahr, kategorie, index) => {
platz: result.platz,
spieler1: result.spieler1,
spieler2: result.spieler2,
bemerkung: result.bemerkung
bemerkung: result.bemerkung,
imageFilename1: result.imageFilename1 || '',
imageFilename2: result.imageFilename2 || ''
}
showModal.value = true
}
@@ -649,7 +701,7 @@ const closeBemerkungModal = () => {
const save = async () => {
try {
// 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 => {
return [
result.jahr,
@@ -657,7 +709,9 @@ const save = async () => {
result.platz,
result.spieler1,
result.spieler2,
result.bemerkung
result.bemerkung,
result.imageFilename1 || '',
result.imageFilename2 || ''
].map(field => `"${field}"`).join(',')
})

View File

@@ -125,7 +125,8 @@ const handleLogin = async () => {
// Redirect based on role
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')
} else {
router.push('/mitgliederbereich')

View File

@@ -337,14 +337,6 @@ const filterData = () => {
const runde = (row.Runde || '').toLowerCase()
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
})
} else if (selectedWettbewerb.value === 'pokal') {
@@ -356,9 +348,6 @@ const filterData = () => {
}
// "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
if (selectedFilter.value === 'all') {
filteredData.value = wettbewerbFiltered
@@ -495,7 +484,6 @@ const filterData = () => {
})
}
console.log('Finale gefilterte Daten:', filteredData.value.length, 'von', spielplanData.value.length)
}
const downloadPDF = () => {

View File

@@ -509,7 +509,7 @@ const bulkResponse = await fetch('/api/members/bulk', {
})
})
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>

View File

@@ -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">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">Mannschaft</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>
</tr>
@@ -79,6 +80,32 @@
</template>
<span v-else class="text-sm text-gray-400">Nur für Vorstand</span>
</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">
<div class="flex items-center space-x-2">
<span
@@ -153,6 +180,30 @@
>
Aus Login-System
</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 class="grid sm:grid-cols-2 gap-3 text-gray-600">
@@ -296,6 +347,19 @@
/>
</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">
<AlertCircle :size="20" class="mr-2" />
{{ errorMessage }}
@@ -494,18 +558,17 @@ const formData = ref({
email: '',
phone: '',
address: '',
notes: ''
notes: '',
isMannschaftsspieler: false
})
const canEdit = computed(() => {
return authStore.role === 'admin' || authStore.role === 'vorstand'
return authStore.hasAnyRole('admin', 'vorstand')
})
const canViewContactData = computed(() => {
// Explicitly check for 'vorstand' role only
const role = authStore.role
console.log('Current role:', role, 'Can view contact:', role === 'vorstand')
return role === 'vorstand'
return authStore.hasRole('vorstand')
})
const loadMembers = async () => {
@@ -529,7 +592,8 @@ const openAddModal = () => {
email: '',
phone: '',
address: '',
notes: ''
notes: '',
isMannschaftsspieler: false
}
showModal.value = true
errorMessage.value = ''
@@ -544,7 +608,8 @@ const openEditModal = (member) => {
email: member.email || '',
phone: member.phone || '',
address: member.address || '',
notes: member.notes || ''
notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true
}
showModal.value = true
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) => {
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
try {

View File

@@ -245,7 +245,7 @@ const formData = ref({
})
const canWrite = computed(() => {
return authStore.role === 'admin' || authStore.role === 'vorstand'
return authStore.hasAnyRole('admin', 'vorstand')
})
const loadNews = async () => {

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -44,13 +44,17 @@
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">{{ gruppe }}</h3>
<div class="space-y-2">
<p
<div
v-for="zeit in zeiten"
:key="zeit.id"
class="text-lg font-semibold text-primary-600"
>
{{ zeit.tag }}: {{ zeit.von }} - {{ zeit.bis }} Uhr
</p>
<p class="text-lg font-semibold text-primary-600">
{{ 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>
<Clock :size="32" class="text-primary-600" />

View File

@@ -16,6 +16,14 @@
:key="trainer.id"
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>
<p class="text-gray-600 mb-4">{{ trainer.name }}</p>
<p class="text-sm text-gray-500">

View File

@@ -250,7 +250,10 @@ const uploadForm = ref({
})
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({
title: 'Galerie - Harheimer TC',

View File

@@ -88,13 +88,37 @@
>
{{ ergebnis.platz }}
</div>
<div>
<span class="font-semibold text-gray-900">
{{ ergebnis.spieler1 }}
<span v-if="ergebnis.spieler2" class="text-gray-600">
/ {{ ergebnis.spieler2 }}
<div class="flex items-center gap-2">
<div v-if="ergebnis.imageFilename1" class="flex-shrink-0">
<img
:src="`/api/personen/${ergebnis.imageFilename1}?width=40&height=40`"
: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 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 class="text-sm text-gray-500">
@@ -147,6 +171,37 @@
</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>
</template>
@@ -156,6 +211,7 @@ import { Trophy } from 'lucide-vue-next'
const results = ref([])
const selectedYear = ref('alle')
const lightboxImage = ref(null)
const loadResults = async () => {
try {
@@ -188,6 +244,7 @@ const loadResults = async () => {
}
values.push(current.trim())
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
if (values.length < 6) return null
return {
@@ -196,7 +253,9 @@ const loadResults = async () => {
platz: values[2].trim(),
spieler1: values[3].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)
} catch (error) {
@@ -268,6 +327,26 @@ const totalDoubles = computed(() => {
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(() => {
loadResults()
})

View File

@@ -13,112 +13,106 @@
<div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose">
<!-- Vorsitzender -->
<div v-if="config.vorstand.vorsitzender.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Vorsitzender</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}
</h4>
<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.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.email">
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.vorsitzender.email }}
</a>
</p>
</div>
</div>
<PersonCard
v-if="config.vorstand.vorsitzender.vorname"
title="Vorsitzender"
:name="`${config.vorstand.vorsitzender.vorname} ${config.vorstand.vorsitzender.nachname}`"
:image-filename="config.vorstand.vorsitzender.imageFilename"
>
<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.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
<p v-if="config.vorstand.vorsitzender.email">
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.vorsitzender.email }}
</a>
</p>
</PersonCard>
<!-- Stellvertreter -->
<div v-if="config.vorstand.stellvertreter.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Stellvertreter</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }}
</h4>
<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.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.email">
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.stellvertreter.email }}
</a>
</p>
</div>
</div>
<PersonCard
v-if="config.vorstand.stellvertreter.vorname"
title="Stellvertreter"
:name="`${config.vorstand.stellvertreter.vorname} ${config.vorstand.stellvertreter.nachname}`"
:image-filename="config.vorstand.stellvertreter.imageFilename"
>
<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.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
<p v-if="config.vorstand.stellvertreter.email">
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.stellvertreter.email }}
</a>
</p>
</PersonCard>
<!-- Kassenwart -->
<div v-if="config.vorstand.kassenwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Kassenwart</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }}
</h4>
<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.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.email">
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.kassenwart.email }}
</a>
</p>
</div>
</div>
<PersonCard
v-if="config.vorstand.kassenwart.vorname"
title="Kassenwart"
:name="`${config.vorstand.kassenwart.vorname} ${config.vorstand.kassenwart.nachname}`"
:image-filename="config.vorstand.kassenwart.imageFilename"
>
<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.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
<p v-if="config.vorstand.kassenwart.email">
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.kassenwart.email }}
</a>
</p>
</PersonCard>
<!-- Schriftführer -->
<div v-if="config.vorstand.schriftfuehrer.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Schriftführer</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }}
</h4>
<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.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.email">
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.schriftfuehrer.email }}
</a>
</p>
</div>
</div>
<PersonCard
v-if="config.vorstand.schriftfuehrer.vorname"
title="Schriftführer"
:name="`${config.vorstand.schriftfuehrer.vorname} ${config.vorstand.schriftfuehrer.nachname}`"
:image-filename="config.vorstand.schriftfuehrer.imageFilename"
>
<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.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
<p v-if="config.vorstand.schriftfuehrer.email">
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.schriftfuehrer.email }}
</a>
</p>
</PersonCard>
<!-- Sportwart -->
<div v-if="config.vorstand.sportwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Sportwart</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }}
</h4>
<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.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.email">
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.sportwart.email }}
</a>
</p>
</div>
</div>
<PersonCard
v-if="config.vorstand.sportwart.vorname"
title="Sportwart"
:name="`${config.vorstand.sportwart.vorname} ${config.vorstand.sportwart.nachname}`"
:image-filename="config.vorstand.sportwart.imageFilename"
>
<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.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
<p v-if="config.vorstand.sportwart.email">
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.sportwart.email }}
</a>
</p>
</PersonCard>
<!-- Jugendwart -->
<div v-if="config.vorstand.jugendwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Jugendwart</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }}
</h4>
<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.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.email">
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.jugendwart.email }}
</a>
</p>
</div>
</div>
<PersonCard
v-if="config.vorstand.jugendwart.vorname"
title="Jugendwart"
:name="`${config.vorstand.jugendwart.vorname} ${config.vorstand.jugendwart.nachname}`"
:image-filename="config.vorstand.jugendwart.imageFilename"
>
<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.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
<p v-if="config.vorstand.jugendwart.email">
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
{{ config.vorstand.jugendwart.email }}
</a>
</p>
</PersonCard>
</div>
</div>
</div>

0
production-setup.sh Normal file → Executable file
View File

View File

@@ -1,49 +1,49 @@
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung
"2024","Einzel","1","Michael Koch","",""
"2024","Einzel","2","Olaf Nüßlein","",""
"2024","Einzel","3","Bernd Meyer","",""
"2024","Doppel","1","Sven Baublies","Johannes Binder",""
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann",""
"2024","Doppel","3","Michael Koch","Jacob Waltenberger",""
"2023","Einzel","1","André Gilzinger","",""
"2023","Einzel","2","Olaf Nüßlein","",""
"2023","Einzel","3","Michael Koch","",""
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder",""
"2023","Doppel","2","Renate Nebel","André Gilzinger",""
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz",""
"2022","Einzel","1","Sven Baublies","",""
"2022","Einzel","2","Thomas Steinbrech","",""
"2022","Einzel","3","André Gilzinger","",""
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt",""
"2022","Doppel","2","Michael Weber","Johannes Binder",""
"2022","Doppel","3","Michael Koch","Renate Nebel",""
"2021","","","","","coronabedingter Ausfall"
"2020","","","","","coronabedingter Ausfall"
"2019","Einzel","1","André Gilzinger","",""
"2019","Einzel","2","Thomas Steinbrech","",""
"2019","Einzel","3","Jürgen Kratz","",""
"2019","Doppel","1","André Gilzinger","Volker Marx",""
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau",""
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt",""
"2018","Einzel","1","André Gilzinger","",""
"2018","Einzel","2","Jürgen Kratz","",""
"2018","Einzel","3","Sven Baublies","",""
"2018","Doppel","1","André Gilzinger","Volker Marx",""
"2018","Doppel","2","Sven Baublies","Helge Stefan",""
"2018","Doppel","3","Jürgen Kratz","Renate Nebel",""
"2017","Einzel","1","André Gilzinger","",""
"2017","Einzel","2","Sven Baublies","",""
"2017","Einzel","3","Olaf Nüßlein","",""
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan",""
"2017","Doppel","2","André Gilzinger","Renate Nebel",""
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt",""
"2016","Herren-Einzel","1","André Gilzinger","",""
"2016","Herren-Einzel","2","Sven Baublies","",""
"2016","Herren-Einzel","3","Olaf Nüßlein","",""
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","",""
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","",""
"2016","Damen-Einzel","3","Renate Nebel","",""
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt",""
"2016","Doppel","2","André Gilzinger","Bernd Meyer",""
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi",""
"2025","Doppel","1","a","b",""
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2
"2024","Einzel","1","Michael Koch","","","",""
"2024","Einzel","2","Olaf Nüßlein","","","",""
"2024","Einzel","3","Bernd Meyer","","","",""
"2024","Doppel","1","Sven Baublies","Johannes Binder","","",""
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann","","",""
"2024","Doppel","3","Michael Koch","Jacob Waltenberger","","",""
"2023","Einzel","1","André Gilzinger","","","",""
"2023","Einzel","2","Olaf Nüßlein","","","",""
"2023","Einzel","3","Michael Koch","","","",""
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder","","",""
"2023","Doppel","2","Renate Nebel","André Gilzinger","","",""
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz","","",""
"2022","Einzel","1","Sven Baublies","","","",""
"2022","Einzel","2","Thomas Steinbrech","","","",""
"2022","Einzel","3","André Gilzinger","","","",""
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt","","",""
"2022","Doppel","2","Michael Weber","Johannes Binder","","",""
"2022","Doppel","3","Michael Koch","Renate Nebel","","",""
"2021","","","","","coronabedingter Ausfall","",""
"2020","","","","","coronabedingter Ausfall","",""
"2019","Einzel","1","André Gilzinger","","","",""
"2019","Einzel","2","Thomas Steinbrech","","","",""
"2019","Einzel","3","Jürgen Kratz","","","",""
"2019","Doppel","1","André Gilzinger","Volker Marx","","",""
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau","","",""
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt","","",""
"2018","Einzel","1","André Gilzinger","","","",""
"2018","Einzel","2","Jürgen Kratz","","","",""
"2018","Einzel","3","Sven Baublies","","","",""
"2018","Doppel","1","André Gilzinger","Volker Marx","","",""
"2018","Doppel","2","Sven Baublies","Helge Stefan","","",""
"2018","Doppel","3","Jürgen Kratz","Renate Nebel","","",""
"2017","Einzel","1","André Gilzinger","","","",""
"2017","Einzel","2","Sven Baublies","","","",""
"2017","Einzel","3","Olaf Nüßlein","","","",""
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan","","",""
"2017","Doppel","2","André Gilzinger","Renate Nebel","","",""
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt","","",""
"2016","Herren-Einzel","1","André Gilzinger","","","",""
"2016","Herren-Einzel","2","Sven Baublies","","","",""
"2016","Herren-Einzel","3","Olaf Nüßlein","","","",""
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","","","",""
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","","","",""
"2016","Damen-Einzel","3","Renate Nebel","","","",""
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt","","",""
"2016","Doppel","2","André Gilzinger","Bernd Meyer","","",""
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi","","",""
"2025","Doppel","1","a","b","","7f6c46f8-b93f-4807-b369-b26e0bba2da5.png","4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png"
1 Jahr Kategorie Platz Spieler1 Spieler2 Bemerkung imageFilename1 imageFilename2
2 2024 Einzel 1 Michael Koch
3 2024 Einzel 2 Olaf Nüßlein
4 2024 Einzel 3 Bernd Meyer
5 2024 Doppel 1 Sven Baublies Johannes Binder
6 2024 Doppel 2 Bernd Meyer Jürgen Dichmann
7 2024 Doppel 3 Michael Koch Jacob Waltenberger
8 2023 Einzel 1 André Gilzinger
9 2023 Einzel 2 Olaf Nüßlein
10 2023 Einzel 3 Michael Koch
11 2023 Doppel 1 Olaf Nüßlein Johannes Binder
12 2023 Doppel 2 Renate Nebel André Gilzinger
13 2023 Doppel 3 Ute Puschmann Jürgen Kratz
14 2022 Einzel 1 Sven Baublies
15 2022 Einzel 2 Thomas Steinbrech
16 2022 Einzel 3 André Gilzinger
17 2022 Doppel 1 Sven Baublies Kristin von Rauchhaupt
18 2022 Doppel 2 Michael Weber Johannes Binder
19 2022 Doppel 3 Michael Koch Renate Nebel
20 2021 coronabedingter Ausfall
21 2020 coronabedingter Ausfall
22 2019 Einzel 1 André Gilzinger
23 2019 Einzel 2 Thomas Steinbrech
24 2019 Einzel 3 Jürgen Kratz
25 2019 Doppel 1 André Gilzinger Volker Marx
26 2019 Doppel 2 Jürgen Kratz Marko Wiedau
27 2019 Doppel 3 Bernd Meyer Kristin von Rauchhaupt
28 2018 Einzel 1 André Gilzinger
29 2018 Einzel 2 Jürgen Kratz
30 2018 Einzel 3 Sven Baublies
31 2018 Doppel 1 André Gilzinger Volker Marx
32 2018 Doppel 2 Sven Baublies Helge Stefan
33 2018 Doppel 3 Jürgen Kratz Renate Nebel
34 2017 Einzel 1 André Gilzinger
35 2017 Einzel 2 Sven Baublies
36 2017 Einzel 3 Olaf Nüßlein
37 2017 Doppel 1 Olaf Nüßlein Helge Stefan
38 2017 Doppel 2 André Gilzinger Renate Nebel
39 2017 Doppel 3 Jürgen Kratz Kristin von Rauchhaupt
40 2016 Herren-Einzel 1 André Gilzinger
41 2016 Herren-Einzel 2 Sven Baublies
42 2016 Herren-Einzel 3 Olaf Nüßlein
43 2016 Damen-Einzel 1 Birgit Haas-Schrödter
44 2016 Damen-Einzel 2 Kristin von Rauchhaupt
45 2016 Damen-Einzel 3 Renate Nebel
46 2016 Doppel 1 Jürgen Kratz Matthias Schmidt
47 2016 Doppel 2 André Gilzinger Bernd Meyer
48 2016 Doppel 3 Sven Baublies Dagmar Bereksasi
49 2025 Doppel 1 a b 7f6c46f8-b93f-4807-b369-b26e0bba2da5.png 4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

View 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
View File

View 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
async function decryptWithFallback(encryptedData, keys) {
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)
// Versuche mit jedem Schlüssel zu entschlüsseln
for (const key of keys) {
@@ -115,6 +130,12 @@ async function reencryptUsers(backupDir, oldKeys) {
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...')
const decrypted = await decryptWithFallback(data, oldKeys)
@@ -129,6 +150,10 @@ async function reencryptUsers(backupDir, oldKeys) {
console.log(' users.json existiert nicht, überspringe...')
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
}
}
@@ -147,6 +172,12 @@ async function reencryptMembers(backupDir, oldKeys) {
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...')
const decrypted = await decryptWithFallback(data, oldKeys)
@@ -161,6 +192,10 @@ async function reencryptMembers(backupDir, oldKeys) {
console.log(' members.json existiert nicht, überspringe...')
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
}
}
@@ -197,28 +232,40 @@ async function reencryptMembershipApplications(backupDir, oldKeys) {
// Prüfe ob encryptedData Feld vorhanden ist
if (parsed.encryptedData) {
console.log(`🔄 Entschlüssele ${file}...`)
// Nur das encryptedData Feld entschlüsseln
const decrypted = await decryptWithFallback(parsed.encryptedData, oldKeys)
console.log(`🔐 Verschlüssele ${file} mit neuem Schlüssel...`)
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++
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
if (await isEncryptedWithNewKey(parsed.encryptedData)) {
console.log(` ${file} ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...`)
skipped++
} else {
console.log(`🔄 Entschlüssele ${file}...`)
// Nur das encryptedData Feld entschlüsseln
const decrypted = await decryptWithFallback(parsed.encryptedData, oldKeys)
console.log(`🔐 Verschlüssele ${file} mit neuem Schlüssel...`)
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')) {
// .data Dateien sind direkt verschlüsselt
console.log(`🔄 Entschlüssele ${file}...`)
const decrypted = await decryptWithFallback(content, oldKeys)
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++
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
if (await isEncryptedWithNewKey(content)) {
console.log(` ${file} ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...`)
skipped++
} else {
console.log(`🔄 Entschlüssele ${file}...`)
const decrypted = await decryptWithFallback(content, oldKeys)
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 {
console.log(` ${file} enthält keine verschlüsselten Daten, überspringe...`)
skipped++

300
scripts/set-admin-password.js Executable file
View 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)
})

View File

@@ -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) => {
try {
@@ -59,6 +59,10 @@ export default defineEventHandler(async (event) => {
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 {
success: true,
@@ -67,8 +71,10 @@ export default defineEventHandler(async (event) => {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
roles: roles
},
// Rückwärtskompatibilität: erste Rolle als role
role: roles[0] || 'mitglied'
}
} catch (error) {
console.error('Login-Fehler:', error)

View File

@@ -23,15 +23,19 @@ export default defineEventHandler(async (event) => {
}
}
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : ['mitglied'])
return {
isLoggedIn: true,
user: {
id: user.id,
email: user.email,
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) {
console.error('Auth-Status-Fehler:', error)

View File

@@ -3,7 +3,7 @@ import fs from 'fs/promises'
import path from 'path'
import { exec } from 'child_process'
import { promisify } from 'util'
import { getUserFromToken } from '../../utils/auth.js'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
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({
statusCode: 403,
statusMessage: 'Keine Berechtigung'

View File

@@ -1,6 +1,6 @@
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken } from '../../utils/auth.js'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
})
}
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung'

View File

@@ -1,7 +1,7 @@
import multer from 'multer'
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken } from '../../utils/auth.js'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
// Multer-Konfiguration für PDF-Uploads
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({
statusCode: 403,
statusMessage: 'Keine Berechtigung'

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const { userId, role } = body
const { userId, roles } = body
const users = await readUsers()
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.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)
await writeUsers(updatedUsers)

View File

@@ -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) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'

View File

@@ -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) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'
@@ -15,16 +15,21 @@ export default defineEventHandler(async (event) => {
const users = await readUsers()
// Return users without passwords
const safeUsers = users.map(u => ({
id: u.id,
email: u.email,
name: u.name,
role: u.role,
phone: u.phone || '',
active: u.active,
created: u.created,
lastLogin: u.lastLogin
}))
const safeUsers = users.map(u => {
const migrated = migrateUserRoles({ ...u })
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
return {
id: u.id,
email: u.email,
name: u.name,
roles: roles,
role: roles[0] || 'mitglied', // Rückwärtskompatibilität
phone: u.phone || '',
active: u.active,
created: u.created,
lastLogin: u.lastLogin
}
})
return {
users: safeUsers

View File

@@ -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) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'

View File

@@ -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) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'
@@ -13,12 +13,15 @@ export default defineEventHandler(async (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({
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)
await writeUsers(updatedUsers)

View File

@@ -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 path from 'path'
@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can edit config
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Bearbeiten der Konfiguration.'

View File

@@ -51,7 +51,7 @@ export default defineEventHandler(async (event) => {
}
const user = await getUserFromToken(token)
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung zum Löschen von Bildern'

View File

@@ -2,7 +2,7 @@ import multer from 'multer'
import fs from 'fs/promises'
import path from 'path'
import sharp from 'sharp'
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
import { getUserFromToken, verifyToken, hasAnyRole } from '../../utils/auth.js'
import { randomUUID } from 'crypto'
// Handle both dev and production paths
@@ -90,7 +90,7 @@ export default defineEventHandler(async (event) => {
}
const user = await getUserFromToken(token)
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can delete members
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Löschen von Mitgliedern.'

View File

@@ -1,6 +1,6 @@
import { verifyToken } from '../utils/auth.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) => {
try {
@@ -75,28 +75,36 @@ export default defineEventHandler(async (event) => {
if (matchedManualIndex !== -1) {
// 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],
hasLogin: true,
loginEmail: user.email,
loginRole: user.role,
lastLogin: user.lastLogin
loginRoles: roles,
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
lastLogin: user.lastLogin,
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true
}
} else {
// 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({
id: user.id,
name: user.name,
email: user.email,
phone: user.phone || '',
address: '',
notes: `Rolle: ${user.role}`,
notes: `Rolle(n): ${roles.join(', ')}`,
source: 'login',
editable: false,
hasLogin: true,
loginEmail: user.email,
loginRole: user.role,
lastLogin: user.lastLogin
loginRoles: roles,
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
lastLogin: user.lastLogin,
isMannschaftsspieler: user.isMannschaftsspieler === true
})
}
}

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
}
// Only admin and vorstand can add/edit members
if (user.role !== 'admin' && user.role !== 'vorstand') {
if (!hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
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 { id, firstName, lastName, geburtsdatum, email, phone, address, notes } = body
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler } = body
if (!firstName || !lastName) {
throw createError({
@@ -73,7 +73,8 @@ export default defineEventHandler(async (event) => {
email: email || '',
phone: phone || '',
address: address || '',
notes: notes || ''
notes: notes || '',
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true'
})
return {

View File

@@ -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 { randomUUID } from 'crypto'
@@ -59,7 +59,7 @@ export default defineEventHandler(async (event) => {
}
// Only admin and vorstand can add members in bulk
if (user.role !== 'admin' && user.role !== 'vorstand') {
if (!hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Bulk-Import von Mitgliedern. Erforderlich: admin oder vorstand Rolle.'

View 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.'
})
}
})

View File

@@ -39,7 +39,8 @@ export default defineEventHandler(async (event) => {
if (token) {
// Authentifizierte Benutzer prüfen
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
isAuthorized = true
}

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can delete news
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Löschen von News.'

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can create/edit news
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Erstellen/Bearbeiten von News.'

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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>
`
})
}

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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>
`
})
}

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View File

@@ -1,4 +1,4 @@
import { verifyToken, getUserById } from '../utils/auth.js'
import { verifyToken, getUserById, migrateUserRoles } from '../utils/auth.js'
export default defineEventHandler(async (event) => {
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 {
success: true,
@@ -37,7 +40,8 @@ export default defineEventHandler(async (event) => {
email: user.email,
name: user.name,
phone: user.phone || '',
role: user.role
roles: roles,
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
}
}
} catch (error) {

View File

@@ -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) => {
try {
@@ -80,6 +80,9 @@ export default defineEventHandler(async (event) => {
await writeUsers(users)
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
return {
success: true,
message: 'Profil erfolgreich aktualisiert.',
@@ -88,7 +91,8 @@ export default defineEventHandler(async (event) => {
email: user.email,
name: user.name,
phone: user.phone,
role: user.role
roles: roles,
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
}
}
} catch (error) {

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can delete termine
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Löschen von Terminen.'

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can manage termine
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Verwalten von Terminen.'

View File

@@ -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'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can create termine
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Erstellen von Terminen.'

View File

@@ -36,7 +36,8 @@
"name": "Torsten Schulz",
"lizenz": "C-Trainer",
"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",
@@ -99,7 +100,8 @@
"plz": "60437",
"ort": "Frankfurt",
"telefon": "06101-9953015",
"email": "rogerdichmann@gmx.de"
"email": "rogerdichmann@gmx.de",
"imageFilename": "c24ef84d-2ae4-4edc-be01-063d9917da04.png"
},
"stellvertreter": {
"vorname": "Jürgen",

View File

@@ -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

View File

@@ -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=

View File

@@ -4,6 +4,27 @@ import { promises as fs } from 'fs'
import path from 'path'
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'
// Handle both dev and production paths
@@ -50,17 +71,17 @@ export async function readUsers() {
const data = await fs.readFile(USERS_FILE, 'utf-8')
const encrypted = isEncrypted(data)
let users = []
if (encrypted) {
const encryptionKey = getEncryptionKey()
try {
return decryptObject(data, encryptionKey)
users = decryptObject(data, encryptionKey)
} catch (decryptError) {
console.error('Fehler beim Entschlüsseln der Benutzerdaten:', decryptError)
try {
const plainData = JSON.parse(data)
users = JSON.parse(data)
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
return plainData
} catch (parseError) {
console.error('Konnte Benutzerdaten weder entschlüsseln noch als JSON lesen')
return []
@@ -68,14 +89,30 @@ export async function readUsers() {
}
} else {
// 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...')
// 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) {
if (error.code === 'ENOENT') {
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() {
try {
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) {
if (error.code === 'ENOENT') {
return []
}
console.error('Fehler beim Lesen der Sessions:', error)
return []
}
}
// Write sessions to file
// Write sessions to file (always encrypted)
export async function writeSessions(sessions) {
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
} catch (error) {
console.error('Fehler beim Schreiben der Sessions:', error)
@@ -133,11 +214,15 @@ export async function verifyPassword(password, hash) {
// Generate JWT token
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(
{
id: user.id,
email: user.email,
role: user.role
roles: roles
},
JWT_SECRET,
{ expiresIn: '7d' }
@@ -156,13 +241,37 @@ export function verifyToken(token) {
// Get user by ID
export async function getUserById(id) {
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
export async function getUserByEmail(email) {
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
@@ -171,7 +280,14 @@ export async function getUserFromToken(token) {
if (!decoded) return null
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

287
server/utils/newsletter.js Normal file
View 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
}

View File

@@ -4,12 +4,22 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
isLoggedIn: false,
user: null,
role: null
roles: [],
role: null // Rückwärtskompatibilität: erste Rolle
}),
getters: {
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')
this.isLoggedIn = response.isLoggedIn
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
} catch (error) {
this.isLoggedIn = false
this.user = null
this.roles = []
this.role = null
return { isLoggedIn: false }
}
@@ -47,6 +59,7 @@ export const useAuthStore = defineStore('auth', {
await $fetch('/api/auth/logout', { method: 'POST' })
this.isLoggedIn = false
this.user = null
this.roles = []
this.role = null
} catch (error) {
console.error('Logout fehlgeschlagen:', error)