Add authentication system with login, password reset, and member area
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -139,3 +139,6 @@ Thumbs.db
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Sensitive data (DO NOT commit production sessions!)
|
||||
# server/data/sessions.json - uncomment for production
|
||||
56
AUTH_README.md
Normal file
56
AUTH_README.md
Normal 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
|
||||
```
|
||||
|
||||
@@ -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
29
middleware/auth.js
Normal 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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
124
pages/cms/index.vue
Normal 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
152
pages/login.vue
Normal 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>
|
||||
|
||||
111
pages/mitgliederbereich/index.vue
Normal file
111
pages/mitgliederbereich/index.vue
Normal 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>
|
||||
|
||||
115
pages/passwort-vergessen.vue
Normal file
115
pages/passwort-vergessen.vue
Normal 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>
|
||||
|
||||
69
server/api/auth/login.post.js
Normal file
69
server/api/auth/login.post.js
Normal 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
|
||||
}
|
||||
})
|
||||
|
||||
26
server/api/auth/logout.post.js
Normal file
26
server/api/auth/logout.post.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
82
server/api/auth/reset-password.post.js
Normal file
82
server/api/auth/reset-password.post.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
45
server/api/auth/status.get.js
Normal file
45
server/api/auth/status.get.js
Normal 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
12
server/data/members.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Admin",
|
||||
"email": "admin@harheimertc.de",
|
||||
"phone": "069-12345678",
|
||||
"role": "admin",
|
||||
"memberSince": "2020-01-01",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
|
||||
2
server/data/sessions.json
Normal file
2
server/data/sessions.json
Normal file
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
|
||||
13
server/data/users.json
Normal file
13
server/data/users.json
Normal 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
125
server/utils/auth.js
Normal 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user