{{ 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 = ''
diff --git a/pages/mitgliederbereich/news.vue b/pages/mitgliederbereich/news.vue
index 66ca9db..4775e76 100644
--- a/pages/mitgliederbereich/news.vue
+++ b/pages/mitgliederbereich/news.vue
@@ -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 () => {
diff --git a/pages/newsletter/confirm.vue b/pages/newsletter/confirm.vue
new file mode 100644
index 0000000..06b0a8b
--- /dev/null
+++ b/pages/newsletter/confirm.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
Newsletter-Anmeldung wird bestätigt...
+
+
+
+
+
+ Fehler
+
+
+ {{ error }}
+
+
+ Zurück zur Anmeldung
+
+
+
+
+
+
+
+
+
diff --git a/pages/newsletter/confirmed.vue b/pages/newsletter/confirmed.vue
new file mode 100644
index 0000000..3528c2e
--- /dev/null
+++ b/pages/newsletter/confirmed.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ {{ alreadyConfirmed ? 'Bereits bestätigt' : 'Anmeldung bestätigt!' }}
+
+
+
+ {{ alreadyConfirmed
+ ? 'Ihre Newsletter-Anmeldung wurde bereits bestätigt.'
+ : 'Vielen Dank! Ihre Newsletter-Anmeldung wurde erfolgreich bestätigt. Sie erhalten ab sofort unseren Newsletter.' }}
+
+
+
+ Zur Startseite
+
+
+
+
+
+
+
+
diff --git a/pages/newsletter/subscribe.vue b/pages/newsletter/subscribe.vue
new file mode 100644
index 0000000..40cc4d8
--- /dev/null
+++ b/pages/newsletter/subscribe.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+ Newsletter abonnieren
+
+
+
+
+
Lade verfügbare Newsletter...
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/newsletter/unsubscribe.vue b/pages/newsletter/unsubscribe.vue
new file mode 100644
index 0000000..db72f96
--- /dev/null
+++ b/pages/newsletter/unsubscribe.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+ Newsletter abmelden
+
+
+
+
+
Lade verfügbare Newsletter...
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/newsletter/unsubscribed.vue b/pages/newsletter/unsubscribed.vue
new file mode 100644
index 0000000..ab662eb
--- /dev/null
+++ b/pages/newsletter/unsubscribed.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ {{ alreadyUnsubscribed ? 'Bereits abgemeldet' : 'Erfolgreich abgemeldet' }}
+
+
+
+ {{ alreadyUnsubscribed
+ ? 'Sie sind bereits vom Newsletter abgemeldet.'
+ : 'Sie wurden erfolgreich vom Newsletter abgemeldet. Sie erhalten keine weiteren Newsletter mehr.' }}
+
+
+
+ Zur Startseite
+
+
+
+
+
+
+
+
diff --git a/pages/verein/galerie.vue b/pages/verein/galerie.vue
index b6b50d2..a88e297 100644
--- a/pages/verein/galerie.vue
+++ b/pages/verein/galerie.vue
@@ -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',
diff --git a/public/images/logos/Harheimer TC.svg b/public/images/logos/Harheimer TC.svg
new file mode 100644
index 0000000..9efda3c
--- /dev/null
+++ b/public/images/logos/Harheimer TC.svg
@@ -0,0 +1,241 @@
+
+
+
+
diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js
index c2e7811..e54200b 100644
--- a/server/api/auth/login.post.js
+++ b/server/api/auth/login.post.js
@@ -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)
diff --git a/server/api/auth/status.get.js b/server/api/auth/status.get.js
index 3a5cdd8..83c6a92 100644
--- a/server/api/auth/status.get.js
+++ b/server/api/auth/status.get.js
@@ -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)
diff --git a/server/api/cms/satzung-upload.post.js b/server/api/cms/satzung-upload.post.js
index d20733b..fbb0dc3 100644
--- a/server/api/cms/satzung-upload.post.js
+++ b/server/api/cms/satzung-upload.post.js
@@ -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'
diff --git a/server/api/cms/save-csv.post.js b/server/api/cms/save-csv.post.js
index 6ae774e..1bda366 100644
--- a/server/api/cms/save-csv.post.js
+++ b/server/api/cms/save-csv.post.js
@@ -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'
diff --git a/server/api/cms/upload-spielplan-pdf.post.js b/server/api/cms/upload-spielplan-pdf.post.js
index 97a0ec2..dfa8352 100644
--- a/server/api/cms/upload-spielplan-pdf.post.js
+++ b/server/api/cms/upload-spielplan-pdf.post.js
@@ -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'
diff --git a/server/api/cms/users/approve.post.js b/server/api/cms/users/approve.post.js
index 830a133..0cf92d1 100644
--- a/server/api/cms/users/approve.post.js
+++ b/server/api/cms/users/approve.post.js
@@ -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)
diff --git a/server/api/cms/users/deactivate.post.js b/server/api/cms/users/deactivate.post.js
index 3173a25..21edec0 100644
--- a/server/api/cms/users/deactivate.post.js
+++ b/server/api/cms/users/deactivate.post.js
@@ -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'
diff --git a/server/api/cms/users/list.get.js b/server/api/cms/users/list.get.js
index 20de04b..bb801f8 100644
--- a/server/api/cms/users/list.get.js
+++ b/server/api/cms/users/list.get.js
@@ -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
diff --git a/server/api/cms/users/reject.post.js b/server/api/cms/users/reject.post.js
index 450a21d..c6bfdf3 100644
--- a/server/api/cms/users/reject.post.js
+++ b/server/api/cms/users/reject.post.js
@@ -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'
diff --git a/server/api/cms/users/update-role.post.js b/server/api/cms/users/update-role.post.js
index 4f84cef..7f63303 100644
--- a/server/api/cms/users/update-role.post.js
+++ b/server/api/cms/users/update-role.post.js
@@ -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)
diff --git a/server/api/config.put.js b/server/api/config.put.js
index ce9a9d5..49af1df 100644
--- a/server/api/config.put.js
+++ b/server/api/config.put.js
@@ -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.'
diff --git a/server/api/galerie/[id].delete.js b/server/api/galerie/[id].delete.js
index 2dc75c6..21a9bf6 100644
--- a/server/api/galerie/[id].delete.js
+++ b/server/api/galerie/[id].delete.js
@@ -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'
diff --git a/server/api/galerie/upload.post.js b/server/api/galerie/upload.post.js
index b1fdf52..afb9794 100644
--- a/server/api/galerie/upload.post.js
+++ b/server/api/galerie/upload.post.js
@@ -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'
diff --git a/server/api/members.delete.js b/server/api/members.delete.js
index 927ea60..b60947b 100644
--- a/server/api/members.delete.js
+++ b/server/api/members.delete.js
@@ -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.'
diff --git a/server/api/members.get.js b/server/api/members.get.js
index 9986cbd..409584a 100644
--- a/server/api/members.get.js
+++ b/server/api/members.get.js
@@ -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
})
}
diff --git a/server/api/members.post.js b/server/api/members.post.js
index 49297eb..495ba7d 100644
--- a/server/api/members.post.js
+++ b/server/api/members.post.js
@@ -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 {
diff --git a/server/api/members/bulk.post.js b/server/api/members/bulk.post.js
index bec3207..bf1b30c 100644
--- a/server/api/members/bulk.post.js
+++ b/server/api/members/bulk.post.js
@@ -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.'
diff --git a/server/api/membership/download/[id].get.js b/server/api/membership/download/[id].get.js
index 1bc16ab..c677e18 100644
--- a/server/api/membership/download/[id].get.js
+++ b/server/api/membership/download/[id].get.js
@@ -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
}
diff --git a/server/api/news.delete.js b/server/api/news.delete.js
index 2e01356..ec335a9 100644
--- a/server/api/news.delete.js
+++ b/server/api/news.delete.js
@@ -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.'
diff --git a/server/api/news.post.js b/server/api/news.post.js
index f291b1b..608774d 100644
--- a/server/api/news.post.js
+++ b/server/api/news.post.js
@@ -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.'
diff --git a/server/api/newsletter/[id].delete.js b/server/api/newsletter/[id].delete.js
new file mode 100644
index 0000000..a3f1bea
--- /dev/null
+++ b/server/api/newsletter/[id].delete.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/[id].put.js b/server/api/newsletter/[id].put.js
new file mode 100644
index 0000000..92d54ba
--- /dev/null
+++ b/server/api/newsletter/[id].put.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/[id]/send.post.js b/server/api/newsletter/[id]/send.post.js
new file mode 100644
index 0000000..ae81e83
--- /dev/null
+++ b/server/api/newsletter/[id]/send.post.js
@@ -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 = `
+
+
Sie erhalten diese E-Mail, weil Sie sich für unseren Newsletter angemeldet haben.
+
+ Newsletter abmelden
+
+
+ `
+ }
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${clubName}
+
+ |
+
+
+
+
+
+
+ ${newsletter.title}
+
+
+ ${newsletter.content}
+
+ ${unsubscribeLink}
+ |
+
+
+
+
+ |
+
+ ${clubName}
+ ${baseUrl}
+
+ |
+
+
+ |
+
+
+
+
+ `
+}
+
+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 === '
') {
+ 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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/check-subscription.get.js b/server/api/newsletter/check-subscription.get.js
new file mode 100644
index 0000000..d2039d9
--- /dev/null
+++ b/server/api/newsletter/check-subscription.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/confirm.get.js b/server/api/newsletter/confirm.get.js
new file mode 100644
index 0000000..c6fc1b9
--- /dev/null
+++ b/server/api/newsletter/confirm.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/create.post.js b/server/api/newsletter/create.post.js
new file mode 100644
index 0000000..88a7d22
--- /dev/null
+++ b/server/api/newsletter/create.post.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/groups/[id]/posts/create.post.js b/server/api/newsletter/groups/[id]/posts/create.post.js
new file mode 100644
index 0000000..055d42a
--- /dev/null
+++ b/server/api/newsletter/groups/[id]/posts/create.post.js
@@ -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 = `
+
+
Sie erhalten diese E-Mail, weil Sie sich für unseren Newsletter angemeldet haben.
+
+ Newsletter abmelden
+
+
+ `
+ }
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${logoDataUri ? ` ` : ''}
+ |
+
+
+ Harheimer TC
+
+
+ ${clubName}
+
+ |
+
+
+ |
+
+
+
+
+
+
+ ${post.title}
+
+
+ ${post.content}
+
+ ${unsubscribeLink}
+ |
+
+
+
+
+ |
+
+ ${clubName}
+ ${baseUrl}
+
+ |
+
+
+ |
+
+
+
+
+ `
+}
+
+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 === '
')) {
+ 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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/groups/[id]/posts/list.get.js b/server/api/newsletter/groups/[id]/posts/list.get.js
new file mode 100644
index 0000000..929a076
--- /dev/null
+++ b/server/api/newsletter/groups/[id]/posts/list.get.js
@@ -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'
+ })
+ }
+})
diff --git a/server/api/newsletter/groups/[id]/subscribers/add.post.js b/server/api/newsletter/groups/[id]/subscribers/add.post.js
new file mode 100644
index 0000000..1c3b510
--- /dev/null
+++ b/server/api/newsletter/groups/[id]/subscribers/add.post.js
@@ -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
+ ? `
+
${customMessage.replace(/\n/g, '
')}
+
`
+ : ''
+
+ const inviterText = inviterName
+ ? `
Sie wurden von ${inviterName} zum Newsletter eingeladen.
`
+ : ''
+
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
+ to: email,
+ subject: `Newsletter-Anmeldung bestätigen - ${groupName} - Harheimer TC`,
+ html: `
+
+
Newsletter-Anmeldung bestätigen
+
Hallo ${name || 'Liebe/r Abonnent/in'},
+ ${inviterText}
+
vielen Dank für Ihre Anmeldung zum Newsletter "${groupName}" des Harheimer TC!
+ ${customMessageHtml}
+
Bitte bestätigen Sie Ihre Anmeldung, indem Sie auf den folgenden Link klicken:
+
+
+ Newsletter-Anmeldung bestätigen
+
+
+
Falls Sie sich nicht angemeldet haben, können Sie diese E-Mail ignorieren.
+
+ Mit sportlichen Grüßen,
+ Ihr Harheimer Tischtennis-Club 1954 e.V.
+
+
+ `
+ })
+}
+
diff --git a/server/api/newsletter/groups/[id]/subscribers/list.get.js b/server/api/newsletter/groups/[id]/subscribers/list.get.js
new file mode 100644
index 0000000..75a0870
--- /dev/null
+++ b/server/api/newsletter/groups/[id]/subscribers/list.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/groups/[id]/subscribers/remove.post.js b/server/api/newsletter/groups/[id]/subscribers/remove.post.js
new file mode 100644
index 0000000..1b2803d
--- /dev/null
+++ b/server/api/newsletter/groups/[id]/subscribers/remove.post.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/groups/create.post.js b/server/api/newsletter/groups/create.post.js
new file mode 100644
index 0000000..1bf9711
--- /dev/null
+++ b/server/api/newsletter/groups/create.post.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/groups/list.get.js b/server/api/newsletter/groups/list.get.js
new file mode 100644
index 0000000..fbcb3cc
--- /dev/null
+++ b/server/api/newsletter/groups/list.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/groups/public-list.get.js b/server/api/newsletter/groups/public-list.get.js
new file mode 100644
index 0000000..2a407c4
--- /dev/null
+++ b/server/api/newsletter/groups/public-list.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/list.get.js b/server/api/newsletter/list.get.js
new file mode 100644
index 0000000..a5fb71d
--- /dev/null
+++ b/server/api/newsletter/list.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/subscribe.post.js b/server/api/newsletter/subscribe.post.js
new file mode 100644
index 0000000..8514059
--- /dev/null
+++ b/server/api/newsletter/subscribe.post.js
@@ -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: `
+
+
Newsletter-Anmeldung bestätigen
+
Hallo ${name || 'Liebe/r Abonnent/in'},
+
vielen Dank für Ihre Anmeldung zum Newsletter "${groupName}" des Harheimer TC!
+
Bitte bestätigen Sie Ihre Anmeldung, indem Sie auf den folgenden Link klicken:
+
+
+ Newsletter-Anmeldung bestätigen
+
+
+
Falls Sie sich nicht angemeldet haben, können Sie diese E-Mail ignorieren.
+
+ Mit sportlichen Grüßen,
+ Ihr Harheimer Tischtennis-Club 1954 e.V.
+
+
+ `
+ })
+}
+
diff --git a/server/api/newsletter/unsubscribe-by-email.post.js b/server/api/newsletter/unsubscribe-by-email.post.js
new file mode 100644
index 0000000..491571c
--- /dev/null
+++ b/server/api/newsletter/unsubscribe-by-email.post.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/newsletter/unsubscribe.get.js b/server/api/newsletter/unsubscribe.get.js
new file mode 100644
index 0000000..629fec4
--- /dev/null
+++ b/server/api/newsletter/unsubscribe.get.js
@@ -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'
+ })
+ }
+})
+
diff --git a/server/api/personen/upload.post.js b/server/api/personen/upload.post.js
index e235bd2..e369694 100644
--- a/server/api/personen/upload.post.js
+++ b/server/api/personen/upload.post.js
@@ -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'
diff --git a/server/api/profile.get.js b/server/api/profile.get.js
index 7f91912..ab8ca0f 100644
--- a/server/api/profile.get.js
+++ b/server/api/profile.get.js
@@ -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) {
diff --git a/server/api/profile.put.js b/server/api/profile.put.js
index a2ba0fb..f0e725e 100644
--- a/server/api/profile.put.js
+++ b/server/api/profile.put.js
@@ -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) {
diff --git a/server/api/termine-manage.delete.js b/server/api/termine-manage.delete.js
index 1d7f73e..05768a0 100644
--- a/server/api/termine-manage.delete.js
+++ b/server/api/termine-manage.delete.js
@@ -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.'
diff --git a/server/api/termine-manage.get.js b/server/api/termine-manage.get.js
index 55c3c54..f3d913b 100644
--- a/server/api/termine-manage.get.js
+++ b/server/api/termine-manage.get.js
@@ -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.'
diff --git a/server/api/termine-manage.post.js b/server/api/termine-manage.post.js
index 160ad66..674f893 100644
--- a/server/api/termine-manage.post.js
+++ b/server/api/termine-manage.post.js
@@ -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.'
diff --git a/server/data/members.json b/server/data/members.json
index 79a860c..7b41e2f 100644
--- a/server/data/members.json
+++ b/server/data/members.json
@@ -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
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/server/data/sessions.json b/server/data/sessions.json
index d2bcc0f..aa6f3ad 100644
--- a/server/data/sessions.json
+++ b/server/data/sessions.json
@@ -1,128 +1 @@
-[
- {
- "id": "1761039055753",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDM5MDU1LCJleHAiOjE3NjE2NDM4NTV9.YkdCMn7N9lmYonyTF-uPE5UbvW2wfOf7kqk_ib0a7Ks",
- "createdAt": "2025-10-21T09:30:55.753Z",
- "expiresAt": "2025-10-28T09:30:55.753Z"
- },
- {
- "id": "1761039524470",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDM5NTI0LCJleHAiOjE3NjE2NDQzMjR9.s3Njws9hnGgnwt_v5SxWCe1BbPp-voOrYf9kW9T11bA",
- "createdAt": "2025-10-21T09:38:44.470Z",
- "expiresAt": "2025-10-28T09:38:44.470Z"
- },
- {
- "id": "1761039571566",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDM5NTcxLCJleHAiOjE3NjE2NDQzNzF9._0zkSloUQP_xPJJuH2JRx4PJgtGkBcH0f86Ku6HKdLo",
- "createdAt": "2025-10-21T09:39:31.566Z",
- "expiresAt": "2025-10-28T09:39:31.566Z"
- },
- {
- "id": "1761039584913",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDM5NTg0LCJleHAiOjE3NjE2NDQzODR9.UM2LNeCMuCBcmG7ei8nS8cS5mWejDg6c0S7DHuAMeM0",
- "createdAt": "2025-10-21T09:39:44.913Z",
- "expiresAt": "2025-10-28T09:39:44.913Z"
- },
- {
- "id": "1761039616843",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDM5NjE2LCJleHAiOjE3NjE2NDQ0MTZ9.hY9asuuJ_dymk8L6taeOcT_RNru-LoJC5MjgjCKg4Is",
- "createdAt": "2025-10-21T09:40:16.843Z",
- "expiresAt": "2025-10-28T09:40:16.843Z"
- },
- {
- "id": "1761048580103",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ4NTgwLCJleHAiOjE3NjE2NTMzODB9._ZEOavin4scWHMI9ofk3MBNdx98K7Q_JossQYxUX4rQ",
- "createdAt": "2025-10-21T12:09:40.103Z",
- "expiresAt": "2025-10-28T12:09:40.103Z"
- },
- {
- "id": "1761049028598",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5MDI4LCJleHAiOjE3NjE2NTM4Mjh9.FsC7CJpxVYVJ824vEPcZol4lKsCKU2I7-L644yeZuMw",
- "createdAt": "2025-10-21T12:17:08.598Z",
- "expiresAt": "2025-10-28T12:17:08.598Z"
- },
- {
- "id": "1761049044973",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5MDQ0LCJleHAiOjE3NjE2NTM4NDR9.fhaWO80YN8qKy9zBCK5nauGWBT29hmr3KxAK_Vlab4E",
- "createdAt": "2025-10-21T12:17:24.973Z",
- "expiresAt": "2025-10-28T12:17:24.973Z"
- },
- {
- "id": "1761049329180",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5MzI5LCJleHAiOjE3NjE2NTQxMjl9.UauragKTqlFMdy7_sZekrLPVM8tlN9NhRjqmCTWM0_8",
- "createdAt": "2025-10-21T12:22:09.180Z",
- "expiresAt": "2025-10-28T12:22:09.180Z"
- },
- {
- "id": "1761049400726",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5NDAwLCJleHAiOjE3NjE2NTQyMDB9._E8-T3_WXsu7RSlrg6GUbxFncUmombaybN1-_UpZbNA",
- "createdAt": "2025-10-21T12:23:20.726Z",
- "expiresAt": "2025-10-28T12:23:20.726Z"
- },
- {
- "id": "1761049449140",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5NDQ5LCJleHAiOjE3NjE2NTQyNDl9.hCs7tx4v2wWLwD7CkWho6YI7oTYWrZwrgmAgfA1EupM",
- "createdAt": "2025-10-21T12:24:09.140Z",
- "expiresAt": "2025-10-28T12:24:09.140Z"
- },
- {
- "id": "1761049572243",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5NTcyLCJleHAiOjE3NjE2NTQzNzJ9.hbqbY8kc1Y98wToNVZVdPIXKd4h-9hwmmYQNRa3-onE",
- "createdAt": "2025-10-21T12:26:12.243Z",
- "expiresAt": "2025-10-28T12:26:12.243Z"
- },
- {
- "id": "1761049616716",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMDQ5NjE2LCJleHAiOjE3NjE2NTQ0MTZ9.1L96eI-m_oA6fbrrgm5P0fji3SGEjsV1UZxmCBKMLm0",
- "createdAt": "2025-10-21T12:26:56.716Z",
- "expiresAt": "2025-10-28T12:26:56.716Z"
- },
- {
- "id": "1761135867778",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMTM1ODY3LCJleHAiOjE3NjE3NDA2Njd9.sddugktX9lZkJjZvbUzAEsP3OTf_n8J6T1tpdjEfeVY",
- "createdAt": "2025-10-22T12:24:27.778Z",
- "expiresAt": "2025-10-29T12:24:27.778Z"
- },
- {
- "id": "1761136733918",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMTM2NzMzLCJleHAiOjE3NjE3NDE1MzN9.rzxassQ4Uj-nXfL2y1sygzshW0YovdYR2GUjosXoPF8",
- "createdAt": "2025-10-22T12:38:53.918Z",
- "expiresAt": "2025-10-29T12:38:53.918Z"
- },
- {
- "id": "1761225427206",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMjI1NDI3LCJleHAiOjE3NjE4MzAyMjd9.MANlBkbOU95y-6yd51m0-hoa941A0uHutVwzl481k9I",
- "createdAt": "2025-10-23T13:17:07.206Z",
- "expiresAt": "2025-10-30T13:17:07.206Z"
- },
- {
- "id": "1762333423617",
- "userId": "1",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYyMzMzNDIzLCJleHAiOjE3NjI5MzgyMjN9.V-L5ethO0VFSOPT2qbsQF2zQYQZSlese1rL5sIFaHbY",
- "createdAt": "2025-11-05T09:03:43.617Z",
- "expiresAt": "2025-11-12T09:03:43.617Z"
- },
- {
- "id": "1766060415179",
- "userId": "1766060412277",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjE3NjYwNjA0MTIyNzciLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzY2MDYwNDE1LCJleHAiOjE3NjY2NjUyMTV9.B-BzHefBmvCZ3qbJ99uN2OMIszcRPJohyj4xknJFExE",
- "createdAt": "2025-12-18T12:20:15.179Z",
- "expiresAt": "2025-12-25T12:20:15.179Z"
- }
-]
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/server/data/users.json b/server/data/users.json
index bec3142..37fbf0f 100644
--- a/server/data/users.json
+++ b/server/data/users.json
@@ -1 +1 @@
-3+uWOe4pSXnAFtrdeCqRG+HvbRIsI2HUcMkzrEBlqEmf/9rasPUIv5xhfS+3vh3BJh89fjff0N9l7C8SZbe/ABq75ffwHa1rT72fExEAQ0B/TntBBARNeACYRtx7j3OTJs0+DPiJvraXshqqVjJQjFVMRk1PdmNs3wbZQ9JkXazyne+Gvb6NJWBAeBv4s5pOe3y06GnUO2ZMsGPX3nKdumbRjFXoNzOWtzMQy9m8GYTAQGtC+dMzRTAjKuPxMLLYT1e8hMhJkhbGDOB0+VgOG2o1zGa+eD2ayoHqzUBf5/RZs09rRspXJZ7HKjvgdnkJuO2lstjQeOFtzoljGE9EC2ueRGUuOsyi0AQrbBhVTj3wWIb5V+mNxNccKv9KDs4/EPwyu8l32Ql6kepNuXofZMbJHuwwYvXIvpIj31HdJP0=
\ No newline at end of file
+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=
\ No newline at end of file
diff --git a/server/utils/auth.js b/server/utils/auth.js
index 9f90e39..2de62f3 100644
--- a/server/utils/auth.js
+++ b/server/utils/auth.js
@@ -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
diff --git a/server/utils/newsletter.js b/server/utils/newsletter.js
new file mode 100644
index 0000000..87ce1a2
--- /dev/null
+++ b/server/utils/newsletter.js
@@ -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
+}
+
diff --git a/stores/auth.js b/stores/auth.js
index 6fcb78e..f6bea63 100644
--- a/stores/auth.js
+++ b/stores/auth.js
@@ -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)