Add authentication system with login, password reset, and member area

This commit is contained in:
Torsten Schulz (local)
2025-10-21 11:23:06 +02:00
parent 4dc07b7b25
commit 2b249577a7
17 changed files with 1080 additions and 2 deletions

5
.gitignore vendored
View File

@@ -138,4 +138,7 @@ Thumbs.db
# Temporary files
*.tmp
*.temp
*.temp
# Sensitive data (DO NOT commit production sessions!)
# server/data/sessions.json - uncomment for production

56
AUTH_README.md Normal file
View File

@@ -0,0 +1,56 @@
# 🔐 Authentifizierung & Mitgliederbereich
## Standard-Login
**E-Mail:** `admin@harheimertc.de`
**Passwort:** `admin123`
⚠️ **WICHTIG:** Ändern Sie dieses Passwort sofort nach der ersten Anmeldung!
## Passwort-Hash generieren
Um einen neuen Benutzer oder ein neues Passwort zu erstellen, können Sie folgenden Node.js-Code verwenden:
```javascript
import bcrypt from 'bcryptjs'
const password = 'IhrNeuesPasswort'
const hash = await bcrypt.hash(password, 10)
console.log(hash)
```
Oder mit einem Online-Tool: https://bcrypt-generator.com/ (Rounds: 10)
## Benutzerrollen
- **admin**: Voller Zugriff auf CMS und Mitgliederbereich
- **vorstand**: Zugriff auf CMS und Mitgliederbereich
- **mitglied**: Nur Mitgliederbereich
## Dateien
- `server/data/users.json` - Benutzerdaten (verschlüsselte Passwörter)
- `server/data/members.json` - Mitgliederdaten (Telefon, E-Mail, etc.)
- `server/data/sessions.json` - Aktive Sessions
## Sicherheit
- Passwörter werden mit bcrypt gehasht (Rounds: 10)
- JWT-Tokens für Sessions (7 Tage gültig)
- HTTP-Only Cookies
- Geschützte API-Routen
- Middleware für geschützte Seiten
## Environment Variables
Fügen Sie in `.env` hinzu:
```
JWT_SECRET=ihr-geheimer-jwt-schluessel-hier
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=ihre-email@example.com
SMTP_PASS=ihr-smtp-passwort
SMTP_FROM=noreply@harheimertc.de
```

View File

@@ -5,13 +5,73 @@
<p class="text-sm text-gray-400">
© {{ currentYear }} Harheimer TC
</p>
<div class="flex items-center space-x-6 text-sm">
<div class="flex items-center space-x-6 text-sm relative">
<NuxtLink to="/impressum" class="text-gray-400 hover:text-primary-400 transition-colors">
Impressum
</NuxtLink>
<NuxtLink to="/kontakt" class="text-gray-400 hover:text-primary-400 transition-colors">
Kontakt
</NuxtLink>
<!-- Mitglieder Dropdown -->
<div class="relative">
<button
@click="toggleMemberMenu"
class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
>
<User :size="16" />
<span>Mitglieder</span>
<ChevronUp :size="14" :class="['transition-transform', isMemberMenuOpen ? 'rotate-0' : 'rotate-180']" />
</button>
<!-- Dropdown Menu (appears above) -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform translate-y-2 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform translate-y-2 opacity-0"
>
<div
v-if="isMemberMenuOpen"
class="absolute bottom-full right-0 mb-2 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden"
>
<template v-if="isLoggedIn">
<NuxtLink
to="/mitgliederbereich"
@click="isMemberMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
>
Mitgliederbereich
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/cms"
@click="isMemberMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
>
CMS
</NuxtLink>
<button
@click="handleLogout"
class="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
>
Abmelden
</button>
</template>
<template v-else>
<NuxtLink
to="/login"
@click="isMemberMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
>
Anmelden
</NuxtLink>
</template>
</div>
</Transition>
</div>
</div>
</div>
</div>
@@ -19,5 +79,57 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { User, ChevronUp } from 'lucide-vue-next'
const currentYear = new Date().getFullYear()
const isMemberMenuOpen = ref(false)
const isLoggedIn = ref(false)
const userRole = ref(null)
const isAdmin = computed(() => {
return userRole.value === 'admin' || userRole.value === 'vorstand'
})
const toggleMemberMenu = () => {
isMemberMenuOpen.value = !isMemberMenuOpen.value
}
const handleLogout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
isLoggedIn.value = false
userRole.value = null
isMemberMenuOpen.value = false
navigateTo('/')
} catch (error) {
console.error('Logout fehlgeschlagen:', error)
}
}
// Check auth status on mount
onMounted(async () => {
try {
const response = await $fetch('/api/auth/status')
isLoggedIn.value = response.isLoggedIn
userRole.value = response.role
} catch (error) {
isLoggedIn.value = false
}
})
// Close menu when clicking outside
const handleClickOutside = (event) => {
if (!event.target.closest('.relative')) {
isMemberMenuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

29
middleware/auth.js Normal file
View File

@@ -0,0 +1,29 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
// Check if route requires auth
const protectedRoutes = ['/mitgliederbereich', '/cms']
const requiresAuth = protectedRoutes.some(route => to.path.startsWith(route))
if (!requiresAuth) {
return
}
// Check auth status
try {
const { data: auth } = await useFetch('/api/auth/status')
if (!auth.value || !auth.value.isLoggedIn) {
return navigateTo('/login?redirect=' + to.path)
}
// Check role for CMS
if (to.path.startsWith('/cms')) {
const isAdmin = auth.value.role === 'admin' || auth.value.role === 'vorstand'
if (!isAdmin) {
return navigateTo('/mitgliederbereich')
}
}
} catch (error) {
return navigateTo('/login?redirect=' + to.path)
}
})

View File

@@ -13,6 +13,8 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.9",
"nuxt": "^3.11.0",
"vue": "^3.4.0"

124
pages/cms/index.vue Normal file
View File

@@ -0,0 +1,124 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Content Management System
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div class="bg-white rounded-xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Willkommen im CMS, {{ user?.name }}!
</h2>
<p class="text-gray-600">
Hier können Sie Inhalte der Website verwalten.
</p>
</div>
<!-- CMS Modules -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<Calendar :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Termine verwalten</h3>
</div>
<p class="text-gray-600 text-sm mb-4">
Termine hinzufügen, bearbeiten und löschen
</p>
<button class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Öffnen
</button>
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<Newspaper :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Interne News</h3>
</div>
<p class="text-gray-600 text-sm mb-4">
News für Mitglieder erstellen und verwalten
</p>
<button class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Öffnen
</button>
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<FileText :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Spielpläne</h3>
</div>
<p class="text-gray-600 text-sm mb-4">
Spielpläne hochladen und verwalten
</p>
<button class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Öffnen
</button>
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<Users :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Mitglieder</h3>
</div>
<p class="text-gray-600 text-sm mb-4">
Mitgliederdaten verwalten
</p>
<button class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Öffnen
</button>
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<Image :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Galerie</h3>
</div>
<p class="text-gray-600 text-sm mb-4">
Bilder hochladen und verwalten
</p>
<button class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Öffnen
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Calendar, Newspaper, FileText, Users, Image } from 'lucide-vue-next'
const user = ref(null)
onMounted(async () => {
try {
const response = await $fetch('/api/auth/status')
if (response.isLoggedIn) {
user.value = response.user
}
} catch (error) {
console.error('Fehler beim Laden der Benutzerdaten:', error)
}
})
definePageMeta({
middleware: 'auth'
})
useHead({
title: 'CMS - Harheimer TC',
})
</script>

152
pages/login.vue Normal file
View File

@@ -0,0 +1,152 @@
<template>
<div class="min-h-full flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-display font-bold text-gray-900">
Mitglieder-Login
</h2>
<p class="mt-2 text-sm text-gray-600">
Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen
</p>
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<form @submit.prevent="handleLogin" class="space-y-6">
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail-Adresse
</label>
<input
id="email"
v-model="formData.email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
:class="{ 'border-red-500': errorMessage }"
placeholder="ihre-email@example.com"
/>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Passwort
</label>
<input
id="password"
v-model="formData.password"
type="password"
required
autocomplete="current-password"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
:class="{ 'border-red-500': errorMessage }"
placeholder="••••••••"
/>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
{{ errorMessage }}
</p>
</div>
<!-- Success Message -->
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
{{ successMessage }}
</p>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<span>{{ isLoading ? 'Anmeldung läuft...' : 'Anmelden' }}</span>
</button>
<!-- Forgot Password Link -->
<div class="text-center">
<NuxtLink
to="/passwort-vergessen"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Passwort vergessen?
</NuxtLink>
</div>
</form>
</div>
<!-- Info Box -->
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
<p class="text-sm text-primary-800 text-center">
<Lock :size="16" class="inline mr-1" />
Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { AlertCircle, Check, Loader2, Lock } from 'lucide-vue-next'
const formData = ref({
email: '',
password: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const handleLogin = async () => {
isLoading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: {
email: formData.value.email,
password: formData.value.password
}
})
if (response.success) {
successMessage.value = 'Anmeldung erfolgreich! Sie werden weitergeleitet...'
// Redirect based on role
setTimeout(() => {
if (response.user.role === 'admin' || response.user.role === 'vorstand') {
navigateTo('/cms')
} else {
navigateTo('/mitgliederbereich')
}
}, 1000)
}
} catch (error) {
errorMessage.value = error.data?.message || 'Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.'
} finally {
isLoading.value = false
}
}
definePageMeta({
layout: 'default'
})
useHead({
title: 'Login - Harheimer TC',
})
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Mitgliederbereich
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div class="bg-white rounded-xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Willkommen, {{ user?.name }}!
</h2>
<p class="text-gray-600 mb-4">
Sie sind als <span class="font-semibold text-primary-600">{{ roleLabel }}</span> angemeldet.
</p>
<p class="text-sm text-gray-500">
Letzter Login: {{ lastLoginFormatted }}
</p>
</div>
<!-- Quick Links -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<NuxtLink
to="/mitgliederbereich/mitglieder"
class="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<Users :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Mitgliederliste</h3>
</div>
<p class="text-gray-600 text-sm">
Kontaktdaten aller Vereinsmitglieder
</p>
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/news"
class="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<Newspaper :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Interne News</h3>
</div>
<p class="text-gray-600 text-sm">
Neuigkeiten nur für Mitglieder
</p>
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/profil"
class="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mr-4">
<UserCog :size="24" class="text-primary-600" />
</div>
<h3 class="text-lg font-semibold text-gray-900">Mein Profil</h3>
</div>
<p class="text-gray-600 text-sm">
Profil bearbeiten und Passwort ändern
</p>
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Users, Newspaper, UserCog } from 'lucide-vue-next'
const user = ref(null)
const roleLabel = computed(() => {
const labels = {
admin: 'Administrator',
vorstand: 'Vorstand',
mitglied: 'Mitglied'
}
return labels[user.value?.role] || 'Mitglied'
})
const lastLoginFormatted = computed(() => {
if (!user.value?.lastLogin) return 'Erste Anmeldung'
return new Date(user.value.lastLogin).toLocaleString('de-DE')
})
onMounted(async () => {
try {
const response = await $fetch('/api/auth/status')
if (response.isLoggedIn) {
user.value = response.user
}
} catch (error) {
console.error('Fehler beim Laden der Benutzerdaten:', error)
}
})
definePageMeta({
middleware: 'auth'
})
useHead({
title: 'Mitgliederbereich - Harheimer TC',
})
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div class="min-h-full flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-display font-bold text-gray-900">
Passwort zurücksetzen
</h2>
<p class="mt-2 text-sm text-gray-600">
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
</p>
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<form @submit.prevent="handleReset" class="space-y-6">
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail-Adresse
</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
:class="{ 'border-red-500': errorMessage }"
placeholder="ihre-email@example.com"
/>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
{{ errorMessage }}
</p>
</div>
<!-- Success Message -->
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
{{ successMessage }}
</p>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<span>{{ isLoading ? 'Wird gesendet...' : 'Passwort zurücksetzen' }}</span>
</button>
<!-- Back to Login -->
<div class="text-center">
<NuxtLink
to="/login"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Zurück zum Login
</NuxtLink>
</div>
</form>
</div>
<!-- Info Box -->
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
<p class="text-sm text-primary-800 text-center">
Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { AlertCircle, Check, Loader2 } from 'lucide-vue-next'
const email = ref('')
const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const handleReset = async () => {
isLoading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await $fetch('/api/auth/reset-password', {
method: 'POST',
body: { email: email.value }
})
if (response.success) {
successMessage.value = 'Eine E-Mail mit weiteren Anweisungen wurde an Ihre E-Mail-Adresse gesendet.'
email.value = ''
}
} catch (error) {
errorMessage.value = error.data?.message || 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.'
} finally {
isLoading.value = false
}
}
useHead({
title: 'Passwort vergessen - Harheimer TC',
})
</script>

View File

@@ -0,0 +1,69 @@
import { readUsers, writeUsers, verifyPassword, generateToken, createSession } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { email, password } = body
if (!email || !password) {
throw createError({
statusCode: 400,
message: 'E-Mail und Passwort sind erforderlich'
})
}
// Find user
const users = await readUsers()
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase())
if (!user) {
throw createError({
statusCode: 401,
message: 'Ungültige Anmeldedaten'
})
}
// Verify password
const isValid = await verifyPassword(password, user.password)
if (!isValid) {
throw createError({
statusCode: 401,
message: 'Ungültige Anmeldedaten'
})
}
// Generate token
const token = generateToken(user)
// Create session
await createSession(user.id, token)
// Update last login
user.lastLogin = new Date().toISOString()
const updatedUsers = users.map(u => u.id === user.id ? user : u)
await writeUsers(updatedUsers)
// Set cookie
setCookie(event, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
// Return user data (without password)
return {
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
}
} catch (error) {
console.error('Login-Fehler:', error)
throw error
}
})

View File

@@ -0,0 +1,26 @@
import { deleteSession } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
if (token) {
await deleteSession(token)
}
// Delete cookie
deleteCookie(event, 'auth_token')
return {
success: true,
message: 'Erfolgreich abgemeldet'
}
} catch (error) {
console.error('Logout-Fehler:', error)
throw createError({
statusCode: 500,
message: 'Abmeldung fehlgeschlagen'
})
}
})

View File

@@ -0,0 +1,82 @@
import { readUsers, hashPassword, writeUsers } from '../../utils/auth.js'
import nodemailer from 'nodemailer'
import crypto from 'crypto'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { email } = body
if (!email) {
throw createError({
statusCode: 400,
message: 'E-Mail-Adresse ist erforderlich'
})
}
// Find user
const users = await readUsers()
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase())
// Always return success (security: don't reveal if email exists)
if (!user) {
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
}
// Generate temporary password
const tempPassword = crypto.randomBytes(8).toString('hex')
const hashedPassword = await hashPassword(tempPassword)
// Update user password
user.password = hashedPassword
user.passwordResetRequired = true
const updatedUsers = users.map(u => u.id === user.id ? user : u)
await writeUsers(updatedUsers)
// Send email with temporary password
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
})
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: user.email,
subject: 'Passwort zurücksetzen - Harheimer TC',
html: `
<h2>Passwort zurücksetzen</h2>
<p>Hallo ${user.name},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p>Ihr temporäres Passwort lautet: <strong>${tempPassword}</strong></p>
<p>Bitte melden Sie sich damit an und ändern Sie Ihr Passwort im Mitgliederbereich.</p>
<br>
<p>Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
}
await transporter.sendMail(mailOptions)
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
} catch (error) {
console.error('Password-Reset-Fehler:', error)
// Don't reveal errors to prevent email enumeration
return {
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'
}
}
})

View File

@@ -0,0 +1,45 @@
import { getUserFromToken } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
if (!token) {
return {
isLoggedIn: false,
user: null,
role: null
}
}
const user = await getUserFromToken(token)
if (!user) {
deleteCookie(event, 'auth_token')
return {
isLoggedIn: false,
user: null,
role: null
}
}
return {
isLoggedIn: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
},
role: user.role
}
} catch (error) {
console.error('Auth-Status-Fehler:', error)
return {
isLoggedIn: false,
user: null,
role: null
}
}
})

12
server/data/members.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"id": "1",
"name": "Admin",
"email": "admin@harheimertc.de",
"phone": "069-12345678",
"role": "admin",
"memberSince": "2020-01-01",
"active": true
}
]

View File

@@ -0,0 +1,2 @@
[]

13
server/data/users.json Normal file
View File

@@ -0,0 +1,13 @@
[
{
"id": "1",
"email": "admin@harheimertc.de",
"password": "$2a$10$rKqW8x3k5vJ8pZ7mN9qL1OXxYzQ2wF3bH4cT6nR8sV9kL0mP1qW2e",
"name": "Admin",
"role": "admin",
"phone": "",
"created": "2025-10-21T00:00:00.000Z",
"lastLogin": null
}
]

125
server/utils/auth.js Normal file
View File

@@ -0,0 +1,125 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { promises as fs } from 'fs'
import path from 'path'
const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production'
const USERS_FILE = path.join(process.cwd(), 'server/data/users.json')
const SESSIONS_FILE = path.join(process.cwd(), 'server/data/sessions.json')
// Read users from file
export async function readUsers() {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8')
return JSON.parse(data)
} catch (error) {
console.error('Fehler beim Lesen der Benutzerdaten:', error)
return []
}
}
// Write users to file
export async function writeUsers(users) {
try {
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8')
return true
} catch (error) {
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
return false
}
}
// Read sessions from file
export async function readSessions() {
try {
const data = await fs.readFile(SESSIONS_FILE, 'utf-8')
return JSON.parse(data)
} catch (error) {
console.error('Fehler beim Lesen der Sessions:', error)
return []
}
}
// Write sessions to file
export async function writeSessions(sessions) {
try {
await fs.writeFile(SESSIONS_FILE, JSON.stringify(sessions, null, 2), 'utf-8')
return true
} catch (error) {
console.error('Fehler beim Schreiben der Sessions:', error)
return false
}
}
// Hash password
export async function hashPassword(password) {
const salt = await bcrypt.genSalt(10)
return await bcrypt.hash(password, salt)
}
// Verify password
export async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash)
}
// Generate JWT token
export function generateToken(user) {
return jwt.sign(
{
id: user.id,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: '7d' }
)
}
// Verify JWT token
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (error) {
return null
}
}
// Get user from token
export async function getUserFromToken(token) {
const decoded = verifyToken(token)
if (!decoded) return null
const users = await readUsers()
return users.find(u => u.id === decoded.id)
}
// Create session
export async function createSession(userId, token) {
const sessions = await readSessions()
const session = {
id: Date.now().toString(),
userId,
token,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days
}
sessions.push(session)
await writeSessions(sessions)
return session
}
// Delete session
export async function deleteSession(token) {
const sessions = await readSessions()
const filtered = sessions.filter(s => s.token !== token)
await writeSessions(filtered)
}
// Clean expired sessions
export async function cleanExpiredSessions() {
const sessions = await readSessions()
const now = new Date()
const valid = sessions.filter(s => new Date(s.expiresAt) > now)
await writeSessions(valid)
}