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.

This commit is contained in:
Torsten Schulz (local)
2025-12-19 09:51:28 +01:00
parent baf6c59c0d
commit 435e28fd55
69 changed files with 5034 additions and 276 deletions

28
DATENSCHUTZ_UEBERSICHT.md Normal file
View File

@@ -0,0 +1,28 @@
# Übersicht: Verschlüsselung personenbezogener Daten
## ✅ Verschlüsselt:
1. **users.json** - Benutzerdaten (E-Mail, Name, Passwort-Hash)
2. **members.json** - Mitgliederdaten (Name, E-Mail, Telefon, Adresse, Geburtsdatum)
3. **newsletter-posts.json** - Newsletter-Posts mit Empfängerlisten (E-Mail-Adressen)
4. **newsletter-subscribers.json** - Newsletter-Abonnenten (E-Mail, Name) ✅ JETZT VERSCHLÜSSELT
5. **sessions.json** - Session-Tokens mit E-Mail-Adressen ✅ JETZT VERSCHLÜSSELT
6. **membership-applications/*.json** - Mitgliedschaftsanträge (verschlüsselt als `encryptedData`)
## ⚠️ Enthält personenbezogene Daten, aber weniger kritisch:
1. **config.json** - E-Mail-Adressen von Vorstand und Website-Verantwortlichem (öffentliche Kontaktdaten)
- 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
## 📝 Hinweise:
- Alle kritischen Dateien mit personenbezogenen Daten sind verschlüsselt
- Verschlüsselungsschlüssel: `ENCRYPTION_KEY` aus `.env` Datei

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

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

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

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

884
pages/cms/newsletter.vue Normal file
View File

@@ -0,0 +1,884 @@
<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, onMounted } from 'vue'
import { Plus, Loader2, Users, Trash2 } from 'lucide-vue-next'
import RichTextEditor from '~/components/RichTextEditor.vue'
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 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

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

@@ -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,15 @@
</template>
<span v-else class="text-sm text-gray-400">Nur für Vorstand</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="member.isMannschaftsspieler"
class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full"
>
Ja
</span>
<span v-else class="text-sm text-gray-400">-</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span
@@ -153,6 +163,12 @@
>
Aus Login-System
</span>
<span
v-if="member.isMannschaftsspieler"
class="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full"
>
Mannschaftsspieler
</span>
</div>
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
@@ -296,6 +312,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 +523,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 +557,8 @@ const openAddModal = () => {
email: '',
phone: '',
address: '',
notes: ''
notes: '',
isMannschaftsspieler: false
}
showModal.value = true
errorMessage.value = ''
@@ -544,7 +573,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 = ''

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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

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,27 +75,33 @@ 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,
loginRoles: roles,
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
lastLogin: user.lastLogin
}
} 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,
loginRoles: roles,
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
lastLogin: user.lastLogin
})
}

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

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

@@ -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
@@ -69,7 +69,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, 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

@@ -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 @@
3+uWOe4pSXnAFtrdeCqRG+HvbRIsI2HUcMkzrEBlqEmf/9rasPUIv5xhfS+3vh3BJh89fjff0N9l7C8SZbe/ABq75ffwHa1rT72fExEAQ0B/TntBBARNeACYRtx7j3OTJs0+DPiJvraXshqqVjJQjFVMRk1PdmNs3wbZQ9JkXazyne+Gvb6NJWBAeBv4s5pOe3y06GnUO2ZMsGPX3nKdumbRjFXoNzOWtzMQy9m8GYTAQGtC+dMzRTAjKuPxMLLYT1e8hMhJkhbGDOB0+VgOG2o1zGa+eD2ayoHqzUBf5/RZs09rRspXJZ7HKjvgdnkJuO2lstjQeOFtzoljGE9EC2ueRGUuOsyi0AQrbBhVTj3wWIb5V+mNxNccKv9KDs4/EPwyu8l32Ql6kepNuXofZMbJHuwwYvXIvpIj31HdJP0=
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)