Compare commits
88 Commits
backup/rem
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fddde56076 | ||
|
|
c385df4a0c | ||
|
|
e44d3c5c74 | ||
|
|
c409fa6d4b | ||
|
|
0fa19493c5 | ||
|
|
c145a723ed | ||
|
|
d0b15f3e83 | ||
|
|
e60c0f4481 | ||
|
|
27a096546f | ||
|
|
20a1cdd7f2 | ||
|
|
e3825ad217 | ||
|
|
a12f1f7815 | ||
|
|
6fea2749e0 | ||
|
|
18da725567 | ||
|
|
4d5fb43ebc | ||
|
|
986b2056cd | ||
|
|
337c172d07 | ||
|
|
15b8f3c4c1 | ||
|
|
510cfd39f9 | ||
|
|
e0bad51764 | ||
|
|
c1de0c1671 | ||
|
|
2bedbee08d | ||
|
|
9c54b6907e | ||
|
|
edfab28fd3 | ||
|
|
5f79d220cf | ||
|
|
0a82b33afc | ||
|
|
1922e85184 | ||
|
|
0fb8052a77 | ||
|
|
ef2d9353f5 | ||
|
|
1aae808e5f | ||
|
|
75e6d66d25 | ||
|
|
daabeec33c | ||
|
|
0fb58af194 | ||
|
|
8ffd267dfc | ||
|
|
5eee7df7e4 | ||
|
|
7dea265eef | ||
|
|
381ec55fd1 | ||
|
|
c30911daed | ||
|
|
bdc9eef707 | ||
|
|
f7701d698f | ||
|
|
49e7255062 | ||
|
|
74246e6b08 | ||
|
|
6230c96bc9 | ||
|
|
3fb40bd87d | ||
|
|
46c2c14ae8 | ||
|
|
ff8c1970df | ||
|
|
8347a86727 | ||
|
|
9a6d32dcb3 | ||
|
|
161618f6fb | ||
|
|
0b3fba44a4 | ||
|
|
d35e1c9a3e | ||
|
|
528353132a | ||
|
|
cd5e5cd781 | ||
|
|
ebbffcc5c4 | ||
|
|
5c760d7fa8 | ||
|
|
d40073ac7b | ||
|
|
b25cf13d3c | ||
|
|
3287102761 | ||
|
|
08624cabbe | ||
|
|
d37f182928 | ||
|
|
79c45be7c7 | ||
|
|
d52f3ffc8d | ||
|
|
64baaf8535 | ||
|
|
e665495003 | ||
|
|
8f444c59eb | ||
|
|
8117335af9 | ||
|
|
85ec99b08c | ||
|
|
04571e6444 | ||
|
|
5799f97570 | ||
|
|
8ab08f4c09 | ||
|
|
fcf3168692 | ||
|
|
cfd209d7ee | ||
|
|
ee1709ffb2 | ||
|
|
8bb02b6e4a | ||
|
|
7a20af2772 | ||
|
|
3e610e68b6 | ||
|
|
485b21c13e | ||
|
|
08b1edc354 | ||
|
|
6e297c682c | ||
|
|
3d3e22bb1b | ||
|
|
d18b671532 | ||
|
|
af3c0164ef | ||
|
|
c681194462 | ||
|
|
141a15a6cb | ||
|
|
ce5915a3bc | ||
|
|
677140bd33 | ||
|
|
8a1e309eba | ||
|
|
0d533710cd |
@@ -1,17 +1,24 @@
|
|||||||
name: Code Analysis (JS/Vue)
|
name: Code Analysis and Production Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
- name: Workspace sanity check
|
- name: Workspace sanity check
|
||||||
run: |
|
run: |
|
||||||
echo "PWD: $(pwd)"
|
echo "PWD: $(pwd)"
|
||||||
@@ -82,3 +89,63 @@ jobs:
|
|||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f ./package-lock.json
|
test -f ./package-lock.json
|
||||||
./osv-scanner --lockfile ./package-lock.json
|
./osv-scanner --lockfile ./package-lock.json
|
||||||
|
|
||||||
|
deploy-production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- name: Prepare SSH
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Test SSH connection
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/id_ed25519 \
|
||||||
|
-o StrictHostKeyChecking=no \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
|
"echo SSH OK"
|
||||||
|
|
||||||
|
- name: Run production deployment script
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/id_ed25519 \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
|
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||||
|
|
||||||
|
deploy-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
|
steps:
|
||||||
|
- name: Prepare SSH
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Test SSH connection
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/id_ed25519 \
|
||||||
|
-o StrictHostKeyChecking=no \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
|
"echo SSH OK"
|
||||||
|
|
||||||
|
- name: Run test deployment script
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/id_ed25519 \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-p "${{ vars.PROD_PORT }}" \
|
||||||
|
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||||
|
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||||
|
|||||||
20
.gitea/workflows/version-gate.yml
Normal file
20
.gitea/workflows/version-gate.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Require Package Version Change
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Check package.json version changed
|
||||||
|
run: scripts/check-package-version-changed.sh origin/main
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -154,3 +154,7 @@ server/data/**
|
|||||||
!server/data/.gitkeep
|
!server/data/.gitkeep
|
||||||
public/data/**
|
public/data/**
|
||||||
public/uploads/**
|
public/uploads/**
|
||||||
|
backups/*
|
||||||
|
public/data
|
||||||
|
server/data
|
||||||
|
public/uploads
|
||||||
|
|||||||
@@ -202,7 +202,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<p class="mt-4 text-sm text-gray-600 text-center">
|
<p class="mt-4 text-sm text-gray-600 text-center">
|
||||||
Ihre Nachricht wird direkt an j.dichmann@gmx.de gesendet
|
Ihre Nachricht wird an den Vorstand und die Trainer weitergeleitet
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
© {{ currentYear }} Harheimer TC 1954 e.V.
|
© {{ currentYear }} Harheimer TC 1954 e.V.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center space-x-6 text-sm relative">
|
<div class="flex items-center space-x-6 text-sm relative">
|
||||||
|
<span
|
||||||
|
v-if="isLoggedIn && appVersion"
|
||||||
|
class="text-xs text-gray-600"
|
||||||
|
title="Version"
|
||||||
|
>
|
||||||
|
v{{ appVersion }}
|
||||||
|
</span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/impressum"
|
to="/impressum"
|
||||||
class="text-gray-400 hover:text-primary-400 transition-colors"
|
class="text-gray-400 hover:text-primary-400 transition-colors"
|
||||||
@@ -89,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { User, ChevronUp } from 'lucide-vue-next'
|
import { User, ChevronUp } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -97,11 +104,26 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const isMemberMenuOpen = ref(false)
|
const isMemberMenuOpen = ref(false)
|
||||||
|
const appVersion = ref('')
|
||||||
|
|
||||||
// Reactive auth state from store
|
// Reactive auth state from store
|
||||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||||
// const isAdmin = computed(() => authStore.isAdmin)
|
// const isAdmin = computed(() => authStore.isAdmin)
|
||||||
|
|
||||||
|
const loadAppVersion = async () => {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
appVersion.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/app/version')
|
||||||
|
appVersion.value = response.version || ''
|
||||||
|
} catch (_error) {
|
||||||
|
appVersion.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleMemberMenu = () => {
|
const toggleMemberMenu = () => {
|
||||||
isMemberMenuOpen.value = !isMemberMenuOpen.value
|
isMemberMenuOpen.value = !isMemberMenuOpen.value
|
||||||
}
|
}
|
||||||
@@ -116,6 +138,10 @@ onMounted(() => {
|
|||||||
authStore.checkAuth()
|
authStore.checkAuth()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(isLoggedIn, () => {
|
||||||
|
loadAppVersion()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (!event.target.closest('.relative')) {
|
if (!event.target.closest('.relative')) {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<span class="text-sm text-gray-500">oder</span>
|
<span class="text-sm text-gray-500">oder</span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/satzung"
|
to="/verein/satzung"
|
||||||
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
|
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Eye
|
<Eye
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<span class="text-sm text-gray-500">oder</span>
|
<span class="text-sm text-gray-500">oder</span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/satzung"
|
to="/verein/satzung"
|
||||||
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
|
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Eye
|
<Eye
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
|
||||||
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
|
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || route.path.startsWith('/links') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
|
||||||
@click="toggleSubmenu('verein')"
|
@click="toggleSubmenu('verein')"
|
||||||
>
|
>
|
||||||
Verein
|
Verein
|
||||||
@@ -177,6 +177,13 @@
|
|||||||
>
|
>
|
||||||
Galerie
|
Galerie
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/links"
|
||||||
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
|
active-class="text-white bg-primary-600"
|
||||||
|
>
|
||||||
|
Links
|
||||||
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Mannschaften Submenu -->
|
<!-- Mannschaften Submenu -->
|
||||||
@@ -299,6 +306,16 @@
|
|||||||
Newsletter
|
Newsletter
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="canAccessContactRequests">
|
||||||
|
<div class="h-3 w-px bg-primary-700" />
|
||||||
|
<NuxtLink
|
||||||
|
to="/cms/kontaktanfragen"
|
||||||
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
|
active-class="text-white bg-primary-600"
|
||||||
|
>
|
||||||
|
Kontaktanfragen
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<div class="h-3 w-px bg-primary-700" />
|
<div class="h-3 w-px bg-primary-700" />
|
||||||
<div class="relative inline-block">
|
<div class="relative inline-block">
|
||||||
@@ -371,6 +388,13 @@
|
|||||||
>
|
>
|
||||||
Mitgliederverwaltung
|
Mitgliederverwaltung
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/cms/kontaktanfragen"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||||
|
@click="showCmsDropdown = false"
|
||||||
|
>
|
||||||
|
Kontaktanfragen
|
||||||
|
</NuxtLink>
|
||||||
<div class="border-t border-gray-700 my-1" />
|
<div class="border-t border-gray-700 my-1" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/einstellungen"
|
to="/cms/einstellungen"
|
||||||
@@ -497,6 +521,13 @@
|
|||||||
>
|
>
|
||||||
Galerie
|
Galerie
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/links"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Links
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/newsletter/subscribe"
|
to="/newsletter/subscribe"
|
||||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
@@ -707,6 +738,16 @@
|
|||||||
Newsletter
|
Newsletter
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="canAccessContactRequests && !isAdmin">
|
||||||
|
<div class="border-t border-primary-700/20 my-2" />
|
||||||
|
<NuxtLink
|
||||||
|
to="/cms/kontaktanfragen"
|
||||||
|
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Kontaktanfragen
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<div class="border-t border-primary-700/20 my-2" />
|
<div class="border-t border-primary-700/20 my-2" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -744,6 +785,13 @@
|
|||||||
>
|
>
|
||||||
Mitgliederverwaltung
|
Mitgliederverwaltung
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/cms/kontaktanfragen"
|
||||||
|
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Kontaktanfragen
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/inhalte"
|
to="/cms/inhalte"
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
@@ -825,11 +873,16 @@ const canAccessNewsletter = computed(() => {
|
|||||||
const store = getAuthStore()
|
const store = getAuthStore()
|
||||||
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
|
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
|
||||||
})
|
})
|
||||||
|
const canAccessContactRequests = computed(() => {
|
||||||
|
const store = getAuthStore()
|
||||||
|
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
|
||||||
|
})
|
||||||
|
|
||||||
// Automatisches Setzen des Submenus basierend auf der Route
|
// Automatisches Setzen des Submenus basierend auf der Route
|
||||||
const currentSubmenu = computed(() => {
|
const currentSubmenu = computed(() => {
|
||||||
const path = route.path
|
const path = route.path
|
||||||
if (path.startsWith('/verein/') || path.startsWith('/vorstand') ||
|
if (path.startsWith('/verein/') || path.startsWith('/vorstand') ||
|
||||||
|
path.startsWith('/links') ||
|
||||||
path.startsWith('/vereinsmeisterschaften')) {
|
path.startsWith('/vereinsmeisterschaften')) {
|
||||||
return 'verein'
|
return 'verein'
|
||||||
}
|
}
|
||||||
@@ -949,7 +1002,7 @@ const toggleSubmenu = (menu) => {
|
|||||||
|
|
||||||
if (menu === 'newsletter' && !path.startsWith('/newsletter')) {
|
if (menu === 'newsletter' && !path.startsWith('/newsletter')) {
|
||||||
navigateTo('/newsletter/subscribe')
|
navigateTo('/newsletter/subscribe')
|
||||||
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften')) {
|
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften') && !path.startsWith('/links')) {
|
||||||
navigateTo('/verein/ueber-uns')
|
navigateTo('/verein/ueber-uns')
|
||||||
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
|
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
|
||||||
navigateTo('/mannschaften')
|
navigateTo('/mannschaften')
|
||||||
|
|||||||
303
components/cms/CmsLinks.vue
Normal file
303
components/cms/CmsLinks.vue
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
|
Links bearbeiten
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Speichert...' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 mb-6">
|
||||||
|
Diese Übersicht wird auf der öffentlichen Seite als Karten dargestellt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div
|
||||||
|
v-for="(section, sectionIndex) in sections"
|
||||||
|
:key="section.id"
|
||||||
|
class="border border-gray-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
v-model="section.title"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Block-Titel"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
|
||||||
|
@click="removeSection(sectionIndex)"
|
||||||
|
>
|
||||||
|
Block löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, itemIndex) in section.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="grid md:grid-cols-12 gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="item.label"
|
||||||
|
type="text"
|
||||||
|
class="md:col-span-4 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Link-Text"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="item.href"
|
||||||
|
type="url"
|
||||||
|
class="md:col-span-5 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="https://..."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="item.description"
|
||||||
|
type="text"
|
||||||
|
class="md:col-span-2 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="md:col-span-1 px-2 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
|
||||||
|
@click="removeItem(sectionIndex, itemIndex)"
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-sm bg-gray-100 text-gray-800 rounded-lg hover:bg-gray-200"
|
||||||
|
@click="addItem(sectionIndex)"
|
||||||
|
>
|
||||||
|
Link hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 text-sm bg-primary-100 text-primary-800 rounded-lg hover:bg-primary-200"
|
||||||
|
@click="addSection"
|
||||||
|
>
|
||||||
|
Block hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const sections = ref([])
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
const c = globalThis?.crypto
|
||||||
|
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
|
||||||
|
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSections = [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
title: 'Ergebnisse & Portale',
|
||||||
|
items: [
|
||||||
|
{ id: createId(), label: 'MyTischtennis.de', href: 'http://www.mytischtennis.de/public/home', description: '(offizielle QTTR-Werte)' },
|
||||||
|
{ id: createId(), label: 'Click-tt Ergebnisse', href: 'http://httv.click-tt.de/', description: '(offizieller Ergebnisdienst HTTV)' },
|
||||||
|
{ id: createId(), label: 'Tischtennis Pur - das Tischtennis Portal', href: 'https://www.tischtennis-pur.de/', description: '(Informationen, Blogs, Fachbeiträge, Tipps)' },
|
||||||
|
{ id: createId(), label: 'Liveticker 2. und 3. TT-Bundesliga', href: 'https://ticker.tt-news.com/', description: '' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
title: 'Verbände',
|
||||||
|
items: [
|
||||||
|
{ id: createId(), label: 'Hessischer Tischtennisverband (HTTV)', href: 'http://www.httv.de/', description: '' },
|
||||||
|
{ id: createId(), label: 'Deutscher Tischtennisbund (DTTB)', href: 'http://www.tischtennis.de/aktuelles/', description: '' },
|
||||||
|
{ id: createId(), label: 'European Table Tennis Union (ETTU)', href: 'http://www.ettu.org/', description: '' },
|
||||||
|
{ id: createId(), label: 'International Table Tennis Federation (ITTF)', href: 'https://www.ittf.com/', description: '' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
title: 'Regionale Links',
|
||||||
|
items: [
|
||||||
|
{ id: createId(), label: 'Stadt Frankfurt', href: 'http://www.frankfurt.de/', description: '' },
|
||||||
|
{ id: createId(), label: 'Vereinsring Harheim', href: 'http://www.harheim.com/', description: '' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
title: 'Partner & Vereine',
|
||||||
|
items: [
|
||||||
|
{ id: createId(), label: 'TTC OE Bad Homburg', href: 'http://www.ttcoe.de/', description: '' },
|
||||||
|
{ id: createId(), label: 'SpVgg Steinkirchen e.V.', href: 'https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis', description: '' },
|
||||||
|
{ id: createId(), label: 'Ergebnisse SpVgg Steinkirchen', href: 'https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/', description: '' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function createHtmlFromSections(inputSections) {
|
||||||
|
const safeSections = Array.isArray(inputSections) ? inputSections : []
|
||||||
|
return safeSections
|
||||||
|
.filter((s) => s.title && Array.isArray(s.items) && s.items.length > 0)
|
||||||
|
.map((section) => {
|
||||||
|
const itemsHtml = section.items
|
||||||
|
.filter((item) => item.label && item.href)
|
||||||
|
.map((item) => {
|
||||||
|
const description = item.description ? ` ${item.description}` : ''
|
||||||
|
return `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${description}</li>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSections(rawSections) {
|
||||||
|
if (!Array.isArray(rawSections) || rawSections.length === 0) {
|
||||||
|
return JSON.parse(JSON.stringify(defaultSections))
|
||||||
|
}
|
||||||
|
return rawSections.map((section) => ({
|
||||||
|
id: section.id || createId(),
|
||||||
|
title: section.title || '',
|
||||||
|
items: Array.isArray(section.items)
|
||||||
|
? section.items.map((item) => ({
|
||||||
|
id: item.id || createId(),
|
||||||
|
label: item.label || '',
|
||||||
|
href: item.href || '',
|
||||||
|
description: item.description || ''
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(html) {
|
||||||
|
if (!html) return ''
|
||||||
|
return String(html)
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLinksHtml(html) {
|
||||||
|
if (!html || typeof html !== 'string') return []
|
||||||
|
const sectionsParsed = []
|
||||||
|
const sectionPattern = /<h2[^>]*>([\s\S]*?)<\/h2>\s*<ul[^>]*>([\s\S]*?)<\/ul>/gi
|
||||||
|
let sectionMatch
|
||||||
|
while ((sectionMatch = sectionPattern.exec(html)) !== null) {
|
||||||
|
const title = stripTags(sectionMatch[1])
|
||||||
|
const ulContent = sectionMatch[2] || ''
|
||||||
|
const itemPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi
|
||||||
|
const items = []
|
||||||
|
let itemMatch
|
||||||
|
while ((itemMatch = itemPattern.exec(ulContent)) !== null) {
|
||||||
|
const liHtml = itemMatch[1] || ''
|
||||||
|
const anchorMatch = liHtml.match(/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
|
||||||
|
const href = anchorMatch ? String(anchorMatch[1]).trim() : ''
|
||||||
|
const label = anchorMatch ? stripTags(anchorMatch[2]) : stripTags(liHtml)
|
||||||
|
let description = ''
|
||||||
|
if (anchorMatch) {
|
||||||
|
description = stripTags(liHtml.replace(anchorMatch[0], ''))
|
||||||
|
}
|
||||||
|
if (label || href || description) {
|
||||||
|
items.push({
|
||||||
|
id: createId(),
|
||||||
|
label,
|
||||||
|
href,
|
||||||
|
description
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (title || items.length > 0) {
|
||||||
|
sectionsParsed.push({
|
||||||
|
id: createId(),
|
||||||
|
title,
|
||||||
|
items
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sectionsParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSection() {
|
||||||
|
sections.value.push({
|
||||||
|
id: createId(),
|
||||||
|
title: '',
|
||||||
|
items: [{ id: createId(), label: '', href: '', description: '' }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSection(index) {
|
||||||
|
sections.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem(sectionIndex) {
|
||||||
|
sections.value[sectionIndex].items.push({
|
||||||
|
id: createId(),
|
||||||
|
label: '',
|
||||||
|
href: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(sectionIndex, itemIndex) {
|
||||||
|
sections.value[sectionIndex].items.splice(itemIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const current = await $fetch('/api/config')
|
||||||
|
const configured = current?.seiten?.linksStructured
|
||||||
|
if (Array.isArray(configured) && configured.length > 0) {
|
||||||
|
sections.value = normalizeSections(configured)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const html = current?.seiten?.links
|
||||||
|
const parsed = parseLinksHtml(html)
|
||||||
|
sections.value = normalizeSections(parsed)
|
||||||
|
} catch {
|
||||||
|
sections.value = JSON.parse(JSON.stringify(defaultSections))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const current = await $fetch('/api/config')
|
||||||
|
const cleanedSections = normalizeSections(sections.value)
|
||||||
|
const linksHtml = createHtmlFromSections(cleanedSections)
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
seiten: {
|
||||||
|
...(current?.seiten || {}),
|
||||||
|
linksStructured: cleanedSections,
|
||||||
|
links: linksHtml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||||
|
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch (_e) { /* no-op */ }
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error?.data?.message || 'Fehler beim Speichern der Links'
|
||||||
|
try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch (_e) { /* no-op */ }
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -2,39 +2,98 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2>
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Mannschaften verwalten
|
||||||
|
</h2>
|
||||||
<div class="w-24 h-1 bg-primary-600" />
|
<div class="w-24 h-1 bg-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<button class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">
|
<button
|
||||||
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen
|
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
@click="openAddModal"
|
||||||
|
>
|
||||||
|
<Plus
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/> Mannschaft hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div>
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
:size="40"
|
||||||
|
class="animate-spin text-primary-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
|
<div
|
||||||
|
v-else
|
||||||
|
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th>
|
Mannschaft
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th>
|
Liga
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Staffelleiter
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Mannschaftsführer
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Spieler
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50">
|
<tr
|
||||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td>
|
v-for="(mannschaft, index) in mannschaften"
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td>
|
:key="index"
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td>
|
class="hover:bg-gray-50"
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td>
|
>
|
||||||
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td>
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
|
{{ mannschaft.mannschaft }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{{ mannschaft.liga }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{{ mannschaft.staffelleiter }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{{ mannschaft.mannschaftsfuehrer }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
<div class="max-w-xs truncate">
|
||||||
|
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||||
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button>
|
<button
|
||||||
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button>
|
class="text-gray-600 hover:text-gray-900"
|
||||||
|
title="Bearbeiten"
|
||||||
|
@click="openEditModal(mannschaft, index)"
|
||||||
|
>
|
||||||
|
<Pencil :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
title="Löschen"
|
||||||
|
@click="confirmDelete(mannschaft, index)"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -42,86 +101,248 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center">
|
<div
|
||||||
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
|
v-if="!isLoading && mannschaften.length === 0"
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3>
|
class="bg-white rounded-xl shadow-lg p-12 text-center"
|
||||||
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p>
|
>
|
||||||
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button>
|
<Users
|
||||||
|
:size="48"
|
||||||
|
class="text-gray-400 mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Keine Mannschaften vorhanden
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Fügen Sie die erste Mannschaft hinzu.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
@click="openAddModal"
|
||||||
|
>
|
||||||
|
Mannschaft hinzufügen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
<!-- Add/Edit Modal -->
|
||||||
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
|
<div
|
||||||
|
v-if="showModal"
|
||||||
|
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
|
@click.self="closeModal"
|
||||||
|
>
|
||||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2>
|
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||||
|
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft">
|
<form
|
||||||
|
class="p-6 space-y-4"
|
||||||
|
@submit.prevent="saveMannschaft"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
|
||||||
<input v-model="formData.mannschaft" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.mannschaft"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
|
||||||
<input v-model="formData.liga" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.liga"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
|
||||||
<input v-model="formData.staffelleiter" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.staffelleiter"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
|
||||||
<input v-model="formData.telefon" type="tel" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.telefon"
|
||||||
|
type="tel"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
|
||||||
<input v-model="formData.heimspieltag" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.heimspieltag"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
|
||||||
<input v-model="formData.spielsystem" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.spielsystem"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
|
||||||
<input v-model="formData.mannschaftsfuehrer" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.mannschaftsfuehrer"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div>
|
<div
|
||||||
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white">
|
v-if="formData.spielerListe.length === 0"
|
||||||
|
class="text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
Noch keine Spieler eingetragen.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(spieler, index) in formData.spielerListe"
|
||||||
|
:key="spieler.id"
|
||||||
|
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
|
||||||
|
>
|
||||||
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||||
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving">
|
<input
|
||||||
|
v-model="spieler.name"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Spielername"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button>
|
<button
|
||||||
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button>
|
type="button"
|
||||||
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button>
|
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Nach oben"
|
||||||
|
:disabled="isSaving || index === 0"
|
||||||
|
@click="moveSpielerUp(index)"
|
||||||
|
>
|
||||||
|
<ChevronUp :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Nach unten"
|
||||||
|
:disabled="isSaving || index === formData.spielerListe.length - 1"
|
||||||
|
@click="moveSpielerDown(index)"
|
||||||
|
>
|
||||||
|
<ChevronDown :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Spieler entfernen"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="removeSpieler(spieler.id)"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen">
|
<select
|
||||||
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option>
|
v-model="moveTargetBySpielerId[spieler.id]"
|
||||||
|
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
|
||||||
|
title="Mannschaft auswählen"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="t in mannschaftenSelectOptions"
|
||||||
|
:key="t"
|
||||||
|
:value="t"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
|
||||||
|
title="In ausgewählte Mannschaft verschieben"
|
||||||
|
@click="moveSpielerToMannschaft(spieler.id)"
|
||||||
|
>
|
||||||
|
<ArrowRight :size="18" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center justify-between">
|
<div class="mt-3 flex items-center justify-between">
|
||||||
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button>
|
<button
|
||||||
<p class="text-xs text-gray-500">Reihenfolge per ↑/↓ ändern.</p>
|
type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="addSpieler()"
|
||||||
|
>
|
||||||
|
<Plus
|
||||||
|
:size="18"
|
||||||
|
class="mr-2"
|
||||||
|
/> Spieler hinzufügen
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Reihenfolge per ↑/↓ ändern.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
|
||||||
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.weitere_informationen_link"
|
||||||
|
type="url"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="https://..."
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||||
|
>
|
||||||
|
<AlertCircle
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/> {{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"><AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}</div>
|
|
||||||
<div class="flex justify-end space-x-4 pt-4">
|
<div class="flex justify-end space-x-4 pt-4">
|
||||||
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
|
<button
|
||||||
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"><Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span></button>
|
type="button"
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="isSaving"
|
||||||
|
:size="20"
|
||||||
|
class="animate-spin mr-2"
|
||||||
|
/><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="filterHasHallKey"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
Nur mit Hallenschlüssel
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
@@ -85,6 +96,9 @@
|
|||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Mannschaft
|
Mannschaft
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
🔑
|
||||||
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
@@ -98,7 +112,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<tr
|
<tr
|
||||||
v-for="member in members"
|
v-for="member in filteredMembers"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="hover:bg-gray-50"
|
class="hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
@@ -177,6 +191,15 @@
|
|||||||
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
v-if="member.hasHallKey"
|
||||||
|
class="text-lg text-amber-600"
|
||||||
|
title="Hat Hallenschlüssel"
|
||||||
|
>
|
||||||
|
🔑
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span
|
<span
|
||||||
@@ -227,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="members.length === 0"
|
v-if="filteredMembers.length === 0"
|
||||||
class="text-center py-12 text-gray-500"
|
class="text-center py-12 text-gray-500"
|
||||||
>
|
>
|
||||||
Keine Mitglieder gefunden.
|
Keine Mitglieder gefunden.
|
||||||
@@ -240,7 +263,7 @@
|
|||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="member in members"
|
v-for="member in filteredMembers"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
|
||||||
>
|
>
|
||||||
@@ -250,6 +273,13 @@
|
|||||||
<h3 class="text-xl font-semibold text-gray-900">
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="member.hasHallKey"
|
||||||
|
class="ml-2 text-amber-600"
|
||||||
|
title="Hat Hallenschlüssel"
|
||||||
|
>
|
||||||
|
🔑
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="member.hasLogin"
|
v-if="member.hasLogin"
|
||||||
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
|
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
|
||||||
@@ -389,7 +419,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="members.length === 0"
|
v-if="filteredMembers.length === 0"
|
||||||
class="text-center py-12 text-gray-500"
|
class="text-center py-12 text-gray-500"
|
||||||
>
|
>
|
||||||
Keine Mitglieder gefunden.
|
Keine Mitglieder gefunden.
|
||||||
@@ -439,12 +469,12 @@
|
|||||||
<input
|
<input
|
||||||
v-model="formData.geburtsdatum"
|
v-model="formData.geburtsdatum"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
:required="isBirthdateRequired"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Wird zur eindeutigen Identifizierung benötigt
|
Für neue Mitglieder erforderlich. Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -504,6 +534,22 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="hasHallKey"
|
||||||
|
v-model="formData.hasHallKey"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="hasHallKey"
|
||||||
|
class="ml-2 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Hat Hallenschlüssel
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||||
@@ -624,10 +670,18 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
<thead class="bg-gray-50 sticky top-0">
|
<thead class="bg-gray-50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Vorname</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th>
|
Vorname
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th>
|
</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Nachname
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Geburtsdatum
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
E-Mail
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
@@ -636,10 +690,18 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="hover:bg-gray-50"
|
class="hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<td class="px-3 py-2">{{ row.firstName || '-' }}</td>
|
<td class="px-3 py-2">
|
||||||
<td class="px-3 py-2">{{ row.lastName || '-' }}</td>
|
{{ row.firstName || '-' }}
|
||||||
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td>
|
</td>
|
||||||
<td class="px-3 py-2">{{ row.email || '-' }}</td>
|
<td class="px-3 py-2">
|
||||||
|
{{ row.lastName || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{{ row.geburtsdatum || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{{ row.email || '-' }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -658,35 +720,65 @@
|
|||||||
class="mb-6"
|
class="mb-6"
|
||||||
>
|
>
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">
|
||||||
|
Import-Ergebnisse
|
||||||
|
</h3>
|
||||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div>
|
<div class="text-2xl font-bold text-green-600">
|
||||||
<div class="text-sm text-gray-600">Importiert</div>
|
{{ bulkImportResults.summary.imported }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Importiert
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div>
|
<div class="text-2xl font-bold text-yellow-600">
|
||||||
<div class="text-sm text-gray-600">Duplikate</div>
|
{{ bulkImportResults.summary.duplicates }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Duplikate
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div>
|
<div class="text-2xl font-bold text-red-600">
|
||||||
<div class="text-sm text-gray-600">Fehler</div>
|
{{ bulkImportResults.summary.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Fehler
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4">
|
<div
|
||||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4>
|
v-if="bulkImportResults.results.duplicates.length > 0"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Duplikate:
|
||||||
|
</h4>
|
||||||
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
|
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
|
||||||
<div v-for="dup in bulkImportResults.results.duplicates" :key="dup.index">
|
<div
|
||||||
|
v-for="dup in bulkImportResults.results.duplicates"
|
||||||
|
:key="dup.index"
|
||||||
|
>
|
||||||
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
|
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4">
|
<div
|
||||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4>
|
v-if="bulkImportResults.results.errors.length > 0"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Fehler:
|
||||||
|
</h4>
|
||||||
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
|
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
|
||||||
<div v-for="err in bulkImportResults.results.errors" :key="err.index">
|
<div
|
||||||
|
v-for="err in bulkImportResults.results.errors"
|
||||||
|
:key="err.index"
|
||||||
|
>
|
||||||
Zeile {{ err.index }}: {{ err.error }}
|
Zeile {{ err.index }}: {{ err.error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -734,6 +826,7 @@ const showModal = ref(false)
|
|||||||
const editingMember = ref(null)
|
const editingMember = ref(null)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const viewMode = ref('cards')
|
const viewMode = ref('cards')
|
||||||
|
const filterHasHallKey = ref(false)
|
||||||
|
|
||||||
// Bulk import state
|
// Bulk import state
|
||||||
const showBulkImportModal = ref(false)
|
const showBulkImportModal = ref(false)
|
||||||
@@ -752,7 +845,8 @@ const formData = ref({
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isMannschaftsspieler: false
|
isMannschaftsspieler: false,
|
||||||
|
hasHallKey: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => {
|
||||||
@@ -763,6 +857,15 @@ const canViewContactData = computed(() => {
|
|||||||
return authStore.hasRole('vorstand')
|
return authStore.hasRole('vorstand')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isBirthdateRequired = computed(() => {
|
||||||
|
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
if (!filterHasHallKey.value) return members.value
|
||||||
|
return members.value.filter(member => member.hasHallKey)
|
||||||
|
})
|
||||||
|
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -777,7 +880,7 @@ const loadMembers = async () => {
|
|||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
editingMember.value = null
|
editingMember.value = null
|
||||||
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false }
|
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false }
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
}
|
}
|
||||||
@@ -792,7 +895,8 @@ const openEditModal = (member) => {
|
|||||||
phone: member.phone || '',
|
phone: member.phone || '',
|
||||||
address: member.address || '',
|
address: member.address || '',
|
||||||
notes: member.notes || '',
|
notes: member.notes || '',
|
||||||
isMannschaftsspieler: member.isMannschaftsspieler === true
|
isMannschaftsspieler: member.isMannschaftsspieler === true,
|
||||||
|
hasHallKey: member.hasHallKey === true
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|||||||
@@ -105,15 +105,23 @@
|
|||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
|
<h4 class="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
Kontaktdaten
|
||||||
|
</h4>
|
||||||
<div class="space-y-1 text-sm text-gray-600">
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
|
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
|
||||||
<p v-if="application.personalData.telefon_privat"><strong>Telefon:</strong> {{ application.personalData.telefon_privat }}</p>
|
<p v-if="application.personalData.telefon_privat">
|
||||||
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p>
|
<strong>Telefon:</strong> {{ application.personalData.telefon_privat }}
|
||||||
|
</p>
|
||||||
|
<p v-if="application.personalData.telefon_mobil">
|
||||||
|
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
|
<h4 class="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
Antragsdetails
|
||||||
|
</h4>
|
||||||
<div class="space-y-1 text-sm text-gray-600">
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
||||||
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
||||||
@@ -141,8 +149,18 @@
|
|||||||
class="text-gray-400 hover:text-gray-600"
|
class="text-gray-400 hover:text-gray-600"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,16 +169,24 @@
|
|||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Persönliche Daten
|
||||||
|
</h3>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
|
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
|
||||||
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
|
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
|
||||||
<p v-if="selectedApplication.personalData.telefon_privat"><strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}</p>
|
<p v-if="selectedApplication.personalData.telefon_privat">
|
||||||
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p>
|
<strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedApplication.personalData.telefon_mobil">
|
||||||
|
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Antragsdetails
|
||||||
|
</h3>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
|
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
|
||||||
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
||||||
@@ -172,14 +198,29 @@
|
|||||||
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeModal">Schließen</button>
|
<button
|
||||||
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="selectedApplication.metadata.pdfGenerated"
|
v-if="selectedApplication.metadata.pdfGenerated"
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
|
||||||
@click="downloadPDF(selectedApplication.id)"
|
@click="downloadPDF(selectedApplication.id)"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
PDF herunterladen
|
PDF herunterladen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,112 +2,335 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2>
|
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||||
|
Spielpläne bearbeiten
|
||||||
|
</h2>
|
||||||
<div class="space-x-3">
|
<div class="space-x-3">
|
||||||
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true">
|
<button
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
|
||||||
|
@click="showUploadModal = true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/></svg>
|
||||||
CSV hochladen
|
CSV hochladen
|
||||||
</button>
|
</button>
|
||||||
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button>
|
<button
|
||||||
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CSV Upload Section -->
|
<!-- CSV Upload Section -->
|
||||||
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
|
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3>
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">
|
||||||
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
Vereins-Spielplan (CSV)
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
v-if="currentFile"
|
||||||
|
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
<svg
|
||||||
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div>
|
class="w-5 h-5 text-green-600 mr-2"
|
||||||
</div>
|
fill="none"
|
||||||
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button>
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/></svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-green-800">
|
||||||
|
{{ currentFile.name }}
|
||||||
|
</p><p class="text-xs text-green-600">
|
||||||
|
{{ currentFile.size }} bytes
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer" :class="{ 'border-primary-400 bg-primary-50': isDragOver }" @click="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop">
|
<button
|
||||||
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
||||||
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p>
|
@click="removeFile"
|
||||||
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p>
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect">
|
</div>
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||||
|
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent
|
||||||
|
@drop.prevent="handleFileDrop"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/></svg>
|
||||||
|
<p class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
CSV-Datei hochladen
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Column Selection -->
|
<!-- Column Selection -->
|
||||||
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8">
|
<div
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3>
|
v-if="csvData.length > 0 && !columnsSelected"
|
||||||
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p>
|
class="bg-white rounded-xl shadow-lg p-6 mb-8"
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">
|
||||||
|
Spalten auswählen
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
|
||||||
|
</p>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50">
|
<div
|
||||||
|
v-for="(header, index) in csvHeaders"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded">
|
<input
|
||||||
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label>
|
:id="`column-${index}`"
|
||||||
|
v-model="selectedColumns[index]"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
:for="`column-${index}`"
|
||||||
|
class="ml-3 text-sm font-medium text-gray-900"
|
||||||
|
>{{ header }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{{ getColumnPreview(index) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-between items-center">
|
<div class="mt-6 flex justify-between items-center">
|
||||||
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div>
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
|
||||||
|
</div>
|
||||||
<div class="space-x-3">
|
<div class="space-x-3">
|
||||||
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button>
|
<button
|
||||||
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button>
|
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
||||||
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button>
|
@click="selectAllColumns"
|
||||||
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button>
|
>
|
||||||
|
Alle auswählen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
||||||
|
@click="deselectAllColumns"
|
||||||
|
>
|
||||||
|
Alle abwählen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
|
||||||
|
@click="suggestHalleColumns"
|
||||||
|
>
|
||||||
|
Halle-Spalten vorschlagen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="selectedColumnsCount === 0"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||||
|
@click="confirmColumnSelection"
|
||||||
|
>
|
||||||
|
Auswahl bestätigen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data Preview -->
|
<!-- Data Preview -->
|
||||||
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6">
|
<div
|
||||||
|
v-if="csvData.length > 0 && columnsSelected"
|
||||||
|
class="bg-white rounded-xl shadow-lg p-6"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3>
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
Datenvorschau
|
||||||
|
</h3>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button>
|
<button
|
||||||
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button>
|
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
|
||||||
|
@click="exportCSV"
|
||||||
|
>
|
||||||
|
CSV exportieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
||||||
|
@click="clearData"
|
||||||
|
>
|
||||||
|
Daten löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead>
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
|
||||||
|
:key="index"
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{{ header }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
|
<tr
|
||||||
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td>
|
v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
|
||||||
|
:key="rowIndex"
|
||||||
|
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="(cell, cellIndex) in row"
|
||||||
|
:key="cellIndex"
|
||||||
|
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
|
||||||
|
>
|
||||||
|
{{ cell }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div>
|
<div
|
||||||
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div>
|
v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
|
||||||
|
class="mt-4 text-center text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm text-gray-600">
|
||||||
|
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
|
<div
|
||||||
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
v-if="csvData.length === 0"
|
||||||
<p class="text-gray-600">Keine CSV-Daten geladen.</p>
|
class="text-center py-12 bg-white rounded-xl shadow-lg"
|
||||||
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p>
|
>
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/></svg>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Keine CSV-Daten geladen.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">
|
||||||
|
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Modal -->
|
<!-- Upload Modal -->
|
||||||
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal">
|
<div
|
||||||
|
v-if="showUploadModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closeUploadModal"
|
||||||
|
>
|
||||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
CSV-Datei hochladen
|
||||||
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div>
|
<div>
|
||||||
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input
|
||||||
<div class="bg-blue-50 p-4 rounded-lg"><h4 class="text-sm font-medium text-blue-800 mb-2">Erwartetes CSV-Format:</h4><div class="text-xs text-blue-700 space-y-1"><p>• Erste Zeile: Spaltenüberschriften</p><p>• Trennzeichen: Komma (,)</p></div></div>
|
ref="modalFileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
@change="handleModalFileSelect"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedFile"
|
||||||
|
class="p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
|
||||||
|
</p><p class="text-xs text-gray-500">
|
||||||
|
{{ selectedFile.size }} bytes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-sm font-medium text-blue-800 mb-2">
|
||||||
|
Erwartetes CSV-Format:
|
||||||
|
</h4><div class="text-xs text-blue-700 space-y-1">
|
||||||
|
<p>• Erste Zeile: Spaltenüberschriften</p><p>• Trennzeichen: Komma (,)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button>
|
<button
|
||||||
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button>
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
@click="closeUploadModal"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="!selectedFile"
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||||
|
@click="processSelectedFile"
|
||||||
|
>
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Processing Modal -->
|
<!-- Processing Modal -->
|
||||||
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div
|
||||||
|
v-if="isProcessing"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
>
|
||||||
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
|
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||||
<p class="text-sm text-gray-600">{{ processingMessage }}</p>
|
Verarbeitung läuft...
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{{ processingMessage }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,36 +11,72 @@
|
|||||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
@click="openAddModal"
|
@click="openAddModal"
|
||||||
>
|
>
|
||||||
<Plus :size="20" class="mr-2" />
|
<Plus
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
Termin hinzufügen
|
Termin hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
<div
|
||||||
<Loader2 :size="40" class="animate-spin text-primary-600" />
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
:size="40"
|
||||||
|
class="animate-spin text-primary-600"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Termine Table -->
|
<!-- Termine Table -->
|
||||||
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
|
<div
|
||||||
|
v-else
|
||||||
|
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th>
|
Datum
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
Uhrzeit
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Titel
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Beschreibung
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Kategorie
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50">
|
<tr
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td>
|
v-for="termin in termine"
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td>
|
:key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
|
||||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td>
|
class="hover:bg-gray-50"
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td>
|
>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ formatDate(termin.datum) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ termin.uhrzeit || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
|
{{ termin.titel }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{{ termin.beschreibung || '-' }}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="{
|
||||||
@@ -54,56 +90,140 @@
|
|||||||
>{{ termin.kategorie }}</span>
|
>{{ termin.kategorie }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||||
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button>
|
<button
|
||||||
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button>
|
class="text-gray-600 hover:text-gray-900"
|
||||||
|
title="Bearbeiten"
|
||||||
|
@click="openEditModal(termin)"
|
||||||
|
>
|
||||||
|
<Pencil :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
title="Löschen"
|
||||||
|
@click="confirmDelete(termin)"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div>
|
<div
|
||||||
|
v-if="termine.length === 0"
|
||||||
|
class="text-center py-12 text-gray-500"
|
||||||
|
>
|
||||||
|
Keine Termine vorhanden.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
<!-- Add/Edit Modal -->
|
||||||
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
|
<div
|
||||||
|
v-if="showModal"
|
||||||
|
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
|
@click.self="closeModal"
|
||||||
|
>
|
||||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2>
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
|
||||||
<form class="space-y-4" @submit.prevent="saveTermin">
|
{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
class="space-y-4"
|
||||||
|
@submit.prevent="saveTermin"
|
||||||
|
>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
|
||||||
<input v-model="formData.datum" type="date" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.datum"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
|
||||||
<input v-model="formData.uhrzeit" type="time" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.uhrzeit"
|
||||||
|
type="time"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
|
||||||
<select v-model="formData.kategorie" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<select
|
||||||
<option value="Training">Training</option>
|
v-model="formData.kategorie"
|
||||||
<option value="Punktspiel">Punktspiel</option>
|
required
|
||||||
<option value="Turnier">Turnier</option>
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
<option value="Veranstaltung">Veranstaltung</option>
|
:disabled="isSaving"
|
||||||
<option value="Sonstiges">Sonstiges</option>
|
>
|
||||||
|
<option value="Training">
|
||||||
|
Training
|
||||||
|
</option>
|
||||||
|
<option value="Punktspiel">
|
||||||
|
Punktspiel
|
||||||
|
</option>
|
||||||
|
<option value="Turnier">
|
||||||
|
Turnier
|
||||||
|
</option>
|
||||||
|
<option value="Veranstaltung">
|
||||||
|
Veranstaltung
|
||||||
|
</option>
|
||||||
|
<option value="Sonstiges">
|
||||||
|
Sonstiges
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
|
||||||
<input v-model="formData.titel" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
<input
|
||||||
|
v-model="formData.titel"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||||
<textarea v-model="formData.beschreibung" rows="3" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving" />
|
<textarea
|
||||||
|
v-model="formData.beschreibung"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
<div
|
||||||
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}
|
v-if="errorMessage"
|
||||||
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||||
|
>
|
||||||
|
<AlertCircle
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/> {{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-4 pt-4">
|
<div class="flex justify-end space-x-4 pt-4">
|
||||||
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
|
<button
|
||||||
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving">
|
type="button"
|
||||||
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" />
|
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="isSaving"
|
||||||
|
:size="20"
|
||||||
|
class="animate-spin mr-2"
|
||||||
|
/>
|
||||||
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,36 +18,108 @@
|
|||||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||||
<!-- Formatierung -->
|
<!-- Formatierung -->
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button>
|
<button
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button>
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button>
|
@click="format('bold')"
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button>
|
>
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button>
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('italic')"
|
||||||
|
>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(1)"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(2)"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(3)"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Listen -->
|
<!-- Listen -->
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')">•</button>
|
<button
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button>
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('insertUnorderedList')"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('insertOrderedList')"
|
||||||
|
>
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Schnellzugriff für Regeln -->
|
<!-- Schnellzugriff für Regeln -->
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button>
|
<button
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button>
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button>
|
@click="insertRuleTemplate('generic')"
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button>
|
>
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button>
|
Neue Regel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
|
||||||
|
@click="insertRuleTemplate('basic')"
|
||||||
|
>
|
||||||
|
Grundregel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
|
||||||
|
@click="insertRuleTemplate('penalty')"
|
||||||
|
>
|
||||||
|
Strafregel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
|
||||||
|
@click="insertRuleTemplate('service')"
|
||||||
|
>
|
||||||
|
Aufschlag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
|
||||||
|
@click="deleteCurrentRule()"
|
||||||
|
>
|
||||||
|
Regel löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Weitere Tools -->
|
<!-- Weitere Tools -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button>
|
<button
|
||||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button>
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="createLink()"
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="removeFormat()"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hilfe-Sektion -->
|
<!-- Hilfe-Sektion -->
|
||||||
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3>
|
<h3 class="text-lg font-semibold text-blue-900 mb-2">
|
||||||
|
So arbeiten Sie mit Regel-Kästchen:
|
||||||
|
</h3>
|
||||||
<div class="text-sm text-blue-800 space-y-2">
|
<div class="text-sm text-blue-800 space-y-2">
|
||||||
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
|
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
|
||||||
<ul class="ml-4 space-y-1">
|
<ul class="ml-4 space-y-1">
|
||||||
|
|||||||
@@ -54,6 +54,108 @@ has_tracked_files_under() {
|
|||||||
git ls-files "$prefix" | head -n 1 | grep -q .
|
git ls-files "$prefix" | head -n 1 | grep -q .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
if [ -f "package-lock.json" ]; then
|
||||||
|
echo " Running: npm ci"
|
||||||
|
npm ci
|
||||||
|
else
|
||||||
|
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies_if_needed() {
|
||||||
|
local cache_dir=".deploy-cache"
|
||||||
|
local lock_hash_file="$cache_dir/package-lock.sha256"
|
||||||
|
local current_lock_hash=""
|
||||||
|
local previous_lock_hash=""
|
||||||
|
|
||||||
|
if [ ! -f "package-lock.json" ]; then
|
||||||
|
echo " package-lock.json fehlt, führe npm install aus..."
|
||||||
|
install_dependencies
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$cache_dir"
|
||||||
|
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
|
||||||
|
if [ -f "$lock_hash_file" ]; then
|
||||||
|
previous_lock_hash="$(cat "$lock_hash_file" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo " node_modules fehlt, installiere Dependencies..."
|
||||||
|
install_dependencies
|
||||||
|
elif [ "$current_lock_hash" != "$previous_lock_hash" ]; then
|
||||||
|
echo " package-lock.json geändert, führe npm ci aus..."
|
||||||
|
install_dependencies
|
||||||
|
else
|
||||||
|
echo " package-lock.json unverändert, überspringe npm ci"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
use_project_node() {
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
if [ -f ".nvmrc" ]; then
|
||||||
|
echo " Using Node version from .nvmrc..."
|
||||||
|
nvm use
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_node_version() {
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Node.js ist nicht im PATH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local node_version
|
||||||
|
node_version="$(node -p 'process.versions.node')"
|
||||||
|
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
|
||||||
|
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
|
||||||
|
echo " nvm install 22"
|
||||||
|
echo " nvm alias default 22"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Node.js $node_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_public_documents_to_build() {
|
||||||
|
if [ ! -d "public/documents" ]; then
|
||||||
|
echo " No public/documents directory to sync"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d ".output/public" ]; then
|
||||||
|
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ".output/public/documents"
|
||||||
|
cp -a "public/documents/." ".output/public/documents/"
|
||||||
|
echo " ✓ public/documents -> .output/public/documents synchronisiert"
|
||||||
|
|
||||||
|
local template_pdf="beitrittserklärung_template.pdf"
|
||||||
|
if [ -f "public/documents/$template_pdf" ]; then
|
||||||
|
local source_size output_size
|
||||||
|
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
|
||||||
|
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
|
||||||
|
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ✓ $template_pdf im Build verifiziert ($output_size bytes)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo "0. Ensuring persistent data directories (recommended)..."
|
echo "0. Ensuring persistent data directories (recommended)..."
|
||||||
# IMPORTANT: Only symlink server/data if it's not tracked by git.
|
# IMPORTANT: Only symlink server/data if it's not tracked by git.
|
||||||
if has_tracked_files_under "server/data"; then
|
if has_tracked_files_under "server/data"; then
|
||||||
@@ -100,6 +202,18 @@ if ls public/data/*.csv >/dev/null 2>&1; then
|
|||||||
else
|
else
|
||||||
echo " No public CSVs to backup (public/data/*.csv not found)"
|
echo " No public CSVs to backup (public/data/*.csv not found)"
|
||||||
fi
|
fi
|
||||||
|
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
|
||||||
|
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$BACKUP_DIR/public-data"
|
||||||
|
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
|
||||||
|
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
|
||||||
|
elif ls public/data/*.csv >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$BACKUP_DIR/public-data"
|
||||||
|
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
|
||||||
|
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
|
||||||
|
else
|
||||||
|
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. Handle local changes and Git Pull
|
# 2. Handle local changes and Git Pull
|
||||||
echo "2. Handling local changes and pulling latest from git..."
|
echo "2. Handling local changes and pulling latest from git..."
|
||||||
@@ -125,7 +239,20 @@ git clean -fd \
|
|||||||
|
|
||||||
# Pull latest changes
|
# Pull latest changes
|
||||||
echo " Pulling latest changes..."
|
echo " Pulling latest changes..."
|
||||||
git pull
|
git fetch origin main
|
||||||
|
git checkout -B main origin/main
|
||||||
|
if ! git reset --hard origin/main; then
|
||||||
|
echo "ERROR: git pull fehlgeschlagen."
|
||||||
|
echo ""
|
||||||
|
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
|
||||||
|
echo "Prüfen:"
|
||||||
|
echo " ssh -T git@tsschulz.de"
|
||||||
|
echo ""
|
||||||
|
echo "Optional auf HTTPS wechseln:"
|
||||||
|
echo " git remote set-url origin https://tsschulz.de/<owner>/<repo>.git"
|
||||||
|
echo "Oder SSH-Key für User $(id -un) hinterlegen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Reset any accidental changes from stash restore (should be none now)
|
# Reset any accidental changes from stash restore (should be none now)
|
||||||
git reset --hard HEAD >/dev/null 2>&1
|
git reset --hard HEAD >/dev/null 2>&1
|
||||||
@@ -141,7 +268,9 @@ fi
|
|||||||
# 3. Install dependencies
|
# 3. Install dependencies
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. Installing dependencies..."
|
echo "3. Installing dependencies..."
|
||||||
npm install
|
use_project_node
|
||||||
|
ensure_node_version
|
||||||
|
install_dependencies_if_needed
|
||||||
|
|
||||||
# 4. Remove old build (but keep data!)
|
# 4. Remove old build (but keep data!)
|
||||||
echo ""
|
echo ""
|
||||||
@@ -164,18 +293,23 @@ if [ -d ".output" ]; then
|
|||||||
echo " ✓ .output gelöscht"
|
echo " ✓ .output gelöscht"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auch .nuxt Cache löschen für sauberen Build
|
# .nuxt standardmäßig behalten (beschleunigt Folge-Builds deutlich).
|
||||||
|
# Für erzwungenen Clean-Build: CLEAN_NUXT_CACHE=1 ./deploy-production.sh
|
||||||
|
if [ "${CLEAN_NUXT_CACHE:-0}" = "1" ]; then
|
||||||
if [ -d ".nuxt" ]; then
|
if [ -d ".nuxt" ]; then
|
||||||
echo " Removing .nuxt cache..."
|
echo " CLEAN_NUXT_CACHE=1 gesetzt: entferne .nuxt cache..."
|
||||||
rm -rf .nuxt
|
rm -rf .nuxt
|
||||||
echo " ✓ .nuxt gelöscht"
|
echo " ✓ .nuxt gelöscht"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo " Behalte .nuxt cache für schnelleren Build (CLEAN_NUXT_CACHE=1 für Clean-Build)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Prüfe, ob node_modules vorhanden ist (für npm run build)
|
# Prüfe, ob node_modules vorhanden ist (für npm run build)
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "WARNING: node_modules fehlt. Installiere Dependencies..."
|
echo "WARNING: node_modules fehlt. Installiere Dependencies..."
|
||||||
npm install
|
install_dependencies
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. Build
|
# 5. Build
|
||||||
@@ -188,28 +322,36 @@ echo " (This may take a few minutes...)"
|
|||||||
echo " Checking dependencies..."
|
echo " Checking dependencies..."
|
||||||
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
|
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
|
||||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||||
npm install
|
install_dependencies
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build mit expliziter Fehlerbehandlung und Output-Capture
|
# Build mit expliziter Fehlerbehandlung und gleichzeitiger Log-Datei
|
||||||
BUILD_OUTPUT=$(npm run build 2>&1)
|
BUILD_LOG_FILE=".deploy-cache/build-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
mkdir -p ".deploy-cache"
|
||||||
|
if npm run build 2>&1 | tee "$BUILD_LOG_FILE"; then
|
||||||
|
BUILD_EXIT_CODE=0
|
||||||
|
else
|
||||||
BUILD_EXIT_CODE=$?
|
BUILD_EXIT_CODE=$?
|
||||||
|
fi
|
||||||
# Zeige Build-Output
|
|
||||||
echo "$BUILD_OUTPUT"
|
|
||||||
|
|
||||||
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
|
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
|
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
|
||||||
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
|
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
|
||||||
|
echo "Build-Log: $BUILD_LOG_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Synchronizing public documents into build output..."
|
||||||
|
sync_public_documents_to_build
|
||||||
|
|
||||||
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
|
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
|
||||||
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then
|
if rg -i "error|failed|missing" "$BUILD_LOG_FILE" >/dev/null 2>&1; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
|
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
|
||||||
echo "Bitte prüfen Sie die Ausgabe oben."
|
echo "Bitte prüfen Sie die Ausgabe oben."
|
||||||
|
echo "Build-Log: $BUILD_LOG_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Prüfe, ob der Build erfolgreich war - mehrere Checks
|
# Prüfe, ob der Build erfolgreich war - mehrere Checks
|
||||||
@@ -443,4 +585,3 @@ echo " pm2 status # View status"
|
|||||||
echo " pm2 restart harheimertc # Restart instance on port 3100"
|
echo " pm2 restart harheimertc # Restart instance on port 3100"
|
||||||
echo " pm2 restart harheimertc-3102 # Restart instance on port 3102"
|
echo " pm2 restart harheimertc-3102 # Restart instance on port 3102"
|
||||||
echo " pm2 restart all # Restart all instances"
|
echo " pm2 restart all # Restart all instances"
|
||||||
|
|
||||||
|
|||||||
214
deploy-test.sh
214
deploy-test.sh
@@ -13,6 +13,14 @@ echo ""
|
|||||||
echo "Working directory: $(pwd)"
|
echo "Working directory: $(pwd)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
|
||||||
|
echo "ERROR: Dieses Script darf nicht mit sudo/root ausgeführt werden."
|
||||||
|
echo "Grund: HOME=/root würde DATA/BACKUP unter /root anlegen und Git-SSH des normalen Users umgehen."
|
||||||
|
echo "Bitte so starten:"
|
||||||
|
echo " ./deploy-test.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
echo "ERROR: Dieses Script muss im Git-Repository ausgeführt werden (kein .git gefunden)."
|
echo "ERROR: Dieses Script muss im Git-Repository ausgeführt werden (kein .git gefunden)."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -37,6 +45,12 @@ ensure_symlink_dir() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d "$src" ]; then
|
if [ -d "$src" ]; then
|
||||||
|
if [ ! -w "$src" ]; then
|
||||||
|
echo "ERROR: Keine Schreibrechte auf $src"
|
||||||
|
echo "Bitte Besitz korrigieren, dann erneut starten:"
|
||||||
|
echo " sudo chown -R $(id -un):$(id -gn) \"$src\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo " Moving $src -> $target (first-time migration)"
|
echo " Moving $src -> $target (first-time migration)"
|
||||||
# Merge existing content into target
|
# Merge existing content into target
|
||||||
cp -a "$src/." "$target/" || true
|
cp -a "$src/." "$target/" || true
|
||||||
@@ -53,6 +67,108 @@ has_tracked_files_under() {
|
|||||||
git ls-files "$prefix" | head -n 1 | grep -q .
|
git ls-files "$prefix" | head -n 1 | grep -q .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
if [ -f "package-lock.json" ]; then
|
||||||
|
echo " Running: npm ci"
|
||||||
|
npm ci
|
||||||
|
else
|
||||||
|
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies_if_needed() {
|
||||||
|
local cache_dir=".deploy-cache"
|
||||||
|
local lock_hash_file="$cache_dir/package-lock.sha256"
|
||||||
|
local current_lock_hash=""
|
||||||
|
local previous_lock_hash=""
|
||||||
|
|
||||||
|
if [ ! -f "package-lock.json" ]; then
|
||||||
|
echo " package-lock.json fehlt, führe npm install aus..."
|
||||||
|
install_dependencies
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$cache_dir"
|
||||||
|
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
|
||||||
|
if [ -f "$lock_hash_file" ]; then
|
||||||
|
previous_lock_hash="$(cat "$lock_hash_file" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo " node_modules fehlt, installiere Dependencies..."
|
||||||
|
install_dependencies
|
||||||
|
elif [ "$current_lock_hash" != "$previous_lock_hash" ]; then
|
||||||
|
echo " package-lock.json geändert, führe npm ci aus..."
|
||||||
|
install_dependencies
|
||||||
|
else
|
||||||
|
echo " package-lock.json unverändert, überspringe npm ci"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
use_project_node() {
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
if [ -f ".nvmrc" ]; then
|
||||||
|
echo " Using Node version from .nvmrc..."
|
||||||
|
nvm use
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_node_version() {
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Node.js ist nicht im PATH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local node_version
|
||||||
|
node_version="$(node -p 'process.versions.node')"
|
||||||
|
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
|
||||||
|
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
|
||||||
|
echo " nvm install 22"
|
||||||
|
echo " nvm alias default 22"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Node.js $node_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_public_documents_to_build() {
|
||||||
|
if [ ! -d "public/documents" ]; then
|
||||||
|
echo " No public/documents directory to sync"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d ".output/public" ]; then
|
||||||
|
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ".output/public/documents"
|
||||||
|
cp -a "public/documents/." ".output/public/documents/"
|
||||||
|
echo " ✓ public/documents -> .output/public/documents synchronisiert"
|
||||||
|
|
||||||
|
local template_pdf="beitrittserklärung_template.pdf"
|
||||||
|
if [ -f "public/documents/$template_pdf" ]; then
|
||||||
|
local source_size output_size
|
||||||
|
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
|
||||||
|
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
|
||||||
|
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ✓ $template_pdf im Build verifiziert ($output_size bytes)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo "0. Ensuring persistent data directories (recommended)..."
|
echo "0. Ensuring persistent data directories (recommended)..."
|
||||||
# IMPORTANT: Only symlink server/data if it's not tracked by git.
|
# IMPORTANT: Only symlink server/data if it's not tracked by git.
|
||||||
if has_tracked_files_under "server/data"; then
|
if has_tracked_files_under "server/data"; then
|
||||||
@@ -92,12 +208,17 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ls public/data/*.csv >/dev/null 2>&1; then
|
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
|
||||||
|
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$BACKUP_DIR/public-data"
|
||||||
|
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
|
||||||
|
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
|
||||||
|
elif ls public/data/*.csv >/dev/null 2>&1; then
|
||||||
mkdir -p "$BACKUP_DIR/public-data"
|
mkdir -p "$BACKUP_DIR/public-data"
|
||||||
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
|
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
|
||||||
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
|
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
|
||||||
else
|
else
|
||||||
echo " No public CSVs to backup (public/data/*.csv not found)"
|
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Handle local changes and Git Pull
|
# 2. Handle local changes and Git Pull
|
||||||
@@ -124,7 +245,20 @@ git clean -fd \
|
|||||||
|
|
||||||
# Pull latest changes
|
# Pull latest changes
|
||||||
echo " Pulling latest changes..."
|
echo " Pulling latest changes..."
|
||||||
git pull
|
git fetch origin dev
|
||||||
|
git checkout -B dev origin/dev
|
||||||
|
if ! git reset --hard origin/dev; then
|
||||||
|
echo "ERROR: git pull fehlgeschlagen."
|
||||||
|
echo ""
|
||||||
|
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
|
||||||
|
echo "Prüfen:"
|
||||||
|
echo " ssh -T git@tsschulz.de"
|
||||||
|
echo ""
|
||||||
|
echo "Optional auf HTTPS wechseln:"
|
||||||
|
echo " git remote set-url origin https://tsschulz.de/<owner>/<repo>.git"
|
||||||
|
echo "Oder SSH-Key für User $(id -un) hinterlegen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Reset any accidental changes from stash restore (should be none now)
|
# Reset any accidental changes from stash restore (should be none now)
|
||||||
git reset --hard HEAD >/dev/null 2>&1
|
git reset --hard HEAD >/dev/null 2>&1
|
||||||
@@ -140,7 +274,9 @@ fi
|
|||||||
# 3. Install dependencies
|
# 3. Install dependencies
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. Installing dependencies..."
|
echo "3. Installing dependencies..."
|
||||||
npm install
|
use_project_node
|
||||||
|
ensure_node_version
|
||||||
|
install_dependencies_if_needed
|
||||||
|
|
||||||
# 4. Remove old build (but keep data!)
|
# 4. Remove old build (but keep data!)
|
||||||
echo ""
|
echo ""
|
||||||
@@ -163,18 +299,23 @@ if [ -d ".output" ]; then
|
|||||||
echo " ✓ .output gelöscht"
|
echo " ✓ .output gelöscht"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auch .nuxt Cache löschen für sauberen Build
|
# .nuxt standardmäßig behalten (beschleunigt Folge-Builds deutlich).
|
||||||
|
# Für erzwungenen Clean-Build: CLEAN_NUXT_CACHE=1 ./deploy-test.sh
|
||||||
|
if [ "${CLEAN_NUXT_CACHE:-0}" = "1" ]; then
|
||||||
if [ -d ".nuxt" ]; then
|
if [ -d ".nuxt" ]; then
|
||||||
echo " Removing .nuxt cache..."
|
echo " CLEAN_NUXT_CACHE=1 gesetzt: entferne .nuxt cache..."
|
||||||
rm -rf .nuxt
|
rm -rf .nuxt
|
||||||
echo " ✓ .nuxt gelöscht"
|
echo " ✓ .nuxt gelöscht"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo " Behalte .nuxt cache für schnelleren Build (CLEAN_NUXT_CACHE=1 für Clean-Build)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Prüfe, ob node_modules vorhanden ist (für npm run build)
|
# Prüfe, ob node_modules vorhanden ist (für npm run build)
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "WARNING: node_modules fehlt. Installiere Dependencies..."
|
echo "WARNING: node_modules fehlt. Installiere Dependencies..."
|
||||||
npm install
|
install_dependencies
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. Build
|
# 5. Build
|
||||||
@@ -187,28 +328,36 @@ echo " (This may take a few minutes...)"
|
|||||||
echo " Checking dependencies..."
|
echo " Checking dependencies..."
|
||||||
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
|
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
|
||||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||||
npm install
|
install_dependencies
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build mit expliziter Fehlerbehandlung und Output-Capture
|
# Build mit expliziter Fehlerbehandlung und gleichzeitiger Log-Datei
|
||||||
BUILD_OUTPUT=$(npm run build 2>&1)
|
BUILD_LOG_FILE=".deploy-cache/build-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
mkdir -p ".deploy-cache"
|
||||||
|
if npm run build 2>&1 | tee "$BUILD_LOG_FILE"; then
|
||||||
|
BUILD_EXIT_CODE=0
|
||||||
|
else
|
||||||
BUILD_EXIT_CODE=$?
|
BUILD_EXIT_CODE=$?
|
||||||
|
fi
|
||||||
# Zeige Build-Output
|
|
||||||
echo "$BUILD_OUTPUT"
|
|
||||||
|
|
||||||
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
|
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
|
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
|
||||||
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
|
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
|
||||||
|
echo "Build-Log: $BUILD_LOG_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Synchronizing public documents into build output..."
|
||||||
|
sync_public_documents_to_build
|
||||||
|
|
||||||
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
|
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
|
||||||
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then
|
if rg -i "error|failed|missing" "$BUILD_LOG_FILE" >/dev/null 2>&1; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
|
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
|
||||||
echo "Bitte prüfen Sie die Ausgabe oben."
|
echo "Bitte prüfen Sie die Ausgabe oben."
|
||||||
|
echo "Build-Log: $BUILD_LOG_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Prüfe, ob der Build erfolgreich war - mehrere Checks
|
# Prüfe, ob der Build erfolgreich war - mehrere Checks
|
||||||
@@ -310,38 +459,33 @@ echo " Restored server/data from backup ($BACKUP_DIR/server-data)."
|
|||||||
|
|
||||||
# Stelle alle CSVs wieder her
|
# Stelle alle CSVs wieder her
|
||||||
if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then
|
if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then
|
||||||
mkdir -p public/data
|
# Restore into internal storage (server/data/public-data)
|
||||||
|
mkdir -p server/data/public-data
|
||||||
# WICHTIG: Überschreibe auch Dateien, die aus dem Git-Repository kommen
|
|
||||||
# Verwende cp mit -f (force) um sicherzustellen, dass Backup-Dateien Vorrang haben
|
|
||||||
for csv_file in "$BACKUP_DIR/public-data"/*.csv; do
|
for csv_file in "$BACKUP_DIR/public-data"/*.csv; do
|
||||||
filename=$(basename "$csv_file")
|
filename=$(basename "$csv_file")
|
||||||
# Überschreibe explizit, auch wenn Datei bereits existiert
|
cp -f "$csv_file" "server/data/public-data/$filename"
|
||||||
cp -f "$csv_file" "public/data/$filename"
|
if [ -f "server/data/public-data/$filename" ]; then
|
||||||
# Stelle sicher, dass die Datei wirklich überschrieben wurde
|
|
||||||
if [ -f "public/data/$filename" ]; then
|
|
||||||
# Prüfe, ob die Datei wirklich vom Backup kommt (Größenvergleich)
|
|
||||||
backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0")
|
backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0")
|
||||||
restored_size=$(stat -f%z "public/data/$filename" 2>/dev/null || stat -c%s "public/data/$filename" 2>/dev/null || echo "0")
|
restored_size=$(stat -f%z "server/data/public-data/$filename" 2>/dev/null || stat -c%s "server/data/public-data/$filename" 2>/dev/null || echo "0")
|
||||||
if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then
|
if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then
|
||||||
echo " ✓ Restored public/data/$filename from backup ($backup_size bytes)"
|
echo " \u2713 Restored server/data/public-data/$filename from backup ($backup_size bytes)"
|
||||||
else
|
else
|
||||||
echo " ⚠ WARNING: public/data/$filename Größe stimmt nicht überein (Backup: $backup_size, Restored: $restored_size)"
|
echo " \u26a0 WARNING: server/data/public-data/$filename size mismatch (Backup: $backup_size, Restored: $restored_size)"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " ❌ ERROR: Konnte public/data/$filename nicht wiederherstellen!"
|
echo " \u274c ERROR: Konnte server/data/public-data/$filename nicht wiederherstellen!"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo " ✓ All public/data/*.csv files restored from backup ($BACKUP_DIR/public-data)."
|
echo " \u2713 All public-data files restored into server/data/public-data ($BACKUP_DIR/public-data)."
|
||||||
|
|
||||||
# Zusätzliche Sicherheit: Entferne public/data Dateien aus Git-Index, falls sie getrackt sind
|
# Optional: synchronize internal public-data into public/data for legacy builds
|
||||||
# (nach dem Restore, damit sie nicht beim nächsten git reset überschrieben werden)
|
# This uses the project's sync script and forces overwrite in public/data.
|
||||||
if git ls-files --error-unmatch public/data/*.csv >/dev/null 2>&1; then
|
if command -v node >/dev/null 2>&1 && [ -f scripts/sync-public-data.js ]; then
|
||||||
echo " WARNING: public/data/*.csv Dateien sind noch im Git getrackt!"
|
echo " Synchronizing server/data/public-data -> public/data (using scripts/sync-public-data.js --force)"
|
||||||
echo " Entferne sie aus dem Git-Index (Dateien bleiben erhalten)..."
|
node scripts/sync-public-data.js --force || echo " WARNING: sync script failed"
|
||||||
git rm --cached public/data/*.csv 2>/dev/null || true
|
else
|
||||||
echo " ✓ public/data/*.csv aus Git-Index entfernt"
|
echo " Note: To publish CSVs to public/data run: node scripts/sync-public-data.js --force"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " No public CSVs to restore"
|
echo " No public CSVs to restore"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default [
|
|||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...vue.configs['flat/recommended'],
|
...vue.configs['flat/recommended'],
|
||||||
{
|
{
|
||||||
files: ['**/*.vue', '**/*.js'],
|
files: ['**/*.vue', '**/*.js', '**/*.mjs', '**/*.cjs'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: parser,
|
parser: parser,
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
@@ -81,6 +81,8 @@ export default [
|
|||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'.output/**',
|
'.output/**',
|
||||||
'.nuxt/**',
|
'.nuxt/**',
|
||||||
|
'**/.output/**',
|
||||||
|
'**/.nuxt/**',
|
||||||
'.next/**',
|
'.next/**',
|
||||||
'dist/**',
|
'dist/**',
|
||||||
'build/**',
|
'build/**',
|
||||||
@@ -88,6 +90,7 @@ export default [
|
|||||||
'*.config.ts',
|
'*.config.ts',
|
||||||
'*.config.cjs',
|
'*.config.cjs',
|
||||||
'*.cjs',
|
'*.cjs',
|
||||||
|
'**/*.cjs',
|
||||||
'temp/**',
|
'temp/**',
|
||||||
'backups/**',
|
'backups/**',
|
||||||
'public/**',
|
'public/**',
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ try {
|
|||||||
// Helper function to create env object
|
// Helper function to create env object
|
||||||
function createEnv(port) {
|
function createEnv(port) {
|
||||||
return {
|
return {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
|
APP_ENV: process.env.APP_ENV || 'test',
|
||||||
PORT: port,
|
PORT: port,
|
||||||
// Secrets/Config (loaded from .env above, if present)
|
// Secrets/Config (loaded from .env above, if present)
|
||||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||||
|
|||||||
@@ -19,12 +19,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
|||||||
if (to.path.startsWith('/cms')) {
|
if (to.path.startsWith('/cms')) {
|
||||||
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
|
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
|
||||||
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
|
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
|
||||||
|
const canAccessContactRequests = roles.includes('admin') || roles.includes('vorstand') || roles.includes('trainer')
|
||||||
|
|
||||||
// Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand
|
// Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand
|
||||||
if (to.path.startsWith('/cms/newsletter')) {
|
if (to.path.startsWith('/cms/newsletter')) {
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return navigateTo('/mitgliederbereich')
|
return navigateTo('/mitgliederbereich')
|
||||||
}
|
}
|
||||||
|
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
|
||||||
|
if (!canAccessContactRequests) {
|
||||||
|
return navigateTo('/mitgliederbereich')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Andere CMS-Seiten nur für Admin oder Vorstand
|
// Andere CMS-Seiten nur für Admin oder Vorstand
|
||||||
const isAdmin = roles.includes('admin') || roles.includes('vorstand')
|
const isAdmin = roles.includes('admin') || roles.includes('vorstand')
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: process.env.NODE_ENV !== 'production' },
|
||||||
|
|
||||||
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
preset: 'node-server',
|
preset: 'node-server',
|
||||||
dev: process.env.NODE_ENV !== 'production'
|
dev: process.env.NODE_ENV !== 'production',
|
||||||
|
sourceMap: false
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
reportCompressedSize: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Erzwinge Dev-Port und Host zuverlässig für `npm run dev`
|
// Erzwinge Dev-Port und Host zuverlässig für `npm run dev`
|
||||||
|
|||||||
3191
package-lock.json
generated
3191
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.0.0",
|
"version": "1.1.6",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt dev --port 3100",
|
"dev": "nuxt dev --port 3100",
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
@@ -12,6 +16,9 @@
|
|||||||
"start": "nuxt start --port 3100",
|
"start": "nuxt start --port 3100",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"check-security": "node scripts/verify-no-public-writes.js",
|
||||||
|
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
||||||
|
"sync-public-data": "node scripts/sync-public-data.js",
|
||||||
"test:watch": "vitest watch",
|
"test:watch": "vitest watch",
|
||||||
"lint": "eslint . --fix"
|
"lint": "eslint . --fix"
|
||||||
},
|
},
|
||||||
@@ -24,7 +31,7 @@
|
|||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^8.0.5",
|
||||||
"nuxt": "^4.1.3",
|
"nuxt": "^4.1.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
@@ -38,11 +45,12 @@
|
|||||||
"@nuxtjs/tailwindcss": "^6.11.0",
|
"@nuxtjs/tailwindcss": "^6.11.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
|
"commander": "^13.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint-plugin-vue": "^10.6.2",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"lucide-vue-next": "^0.344.0",
|
"lucide-vue-next": "^0.344.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.5.12",
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
<option value="newsletter">
|
<option value="newsletter">
|
||||||
Newsletter
|
Newsletter
|
||||||
</option>
|
</option>
|
||||||
|
<option value="trainer">
|
||||||
|
Trainer
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Approve Button -->
|
<!-- Approve Button -->
|
||||||
@@ -103,9 +106,31 @@
|
|||||||
|
|
||||||
<!-- Active Users -->
|
<!-- Active Users -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
|
<div class="flex flex-col gap-3 mb-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
Aktive Benutzer ({{ activeUsers.length }})
|
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||||
|
Aktive Benutzer ({{ sortedActiveUsers.length }})
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
for="user-sort-order"
|
||||||
|
class="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Sortierung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="user-sort-order"
|
||||||
|
v-model="nameSortMode"
|
||||||
|
class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-600"
|
||||||
|
>
|
||||||
|
<option value="firstLast">
|
||||||
|
Vorname Nachname
|
||||||
|
</option>
|
||||||
|
<option value="lastFirst">
|
||||||
|
Nachname, Vorname
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
@@ -132,13 +157,13 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<tr
|
<tr
|
||||||
v-for="user in activeUsers"
|
v-for="user in sortedActiveUsers"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="hover:bg-gray-50"
|
class="hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm font-medium text-gray-900">
|
<div class="text-sm font-medium text-gray-900">
|
||||||
{{ user.name }}
|
{{ getDisplayName(user) }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
@@ -177,10 +202,11 @@
|
|||||||
'bg-red-100 text-red-800': role === 'admin',
|
'bg-red-100 text-red-800': role === 'admin',
|
||||||
'bg-blue-100 text-blue-800': role === 'vorstand',
|
'bg-blue-100 text-blue-800': role === 'vorstand',
|
||||||
'bg-green-100 text-green-800': role === 'newsletter',
|
'bg-green-100 text-green-800': role === 'newsletter',
|
||||||
|
'bg-amber-100 text-amber-800': role === 'trainer',
|
||||||
'bg-gray-100 text-gray-800': role === 'mitglied'
|
'bg-gray-100 text-gray-800': role === 'mitglied'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : 'Mitglied' }}
|
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : role === 'trainer' ? 'Trainer' : 'Mitglied' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -249,7 +275,7 @@
|
|||||||
>
|
>
|
||||||
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
|
||||||
Rollen bearbeiten: {{ editingUser.name }}
|
Rollen bearbeiten: {{ getDisplayName(editingUser) }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
@@ -280,6 +306,15 @@
|
|||||||
>
|
>
|
||||||
<span class="ml-2 text-sm text-gray-700">Newsletter</span>
|
<span class="ml-2 text-sm text-gray-700">Newsletter</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="selectedRoles"
|
||||||
|
type="checkbox"
|
||||||
|
value="trainer"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Trainer</span>
|
||||||
|
</label>
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input
|
<input
|
||||||
v-model="selectedRoles"
|
v-model="selectedRoles"
|
||||||
@@ -337,6 +372,7 @@ const errorMessage = ref('')
|
|||||||
const showRoleModal = ref(false)
|
const showRoleModal = ref(false)
|
||||||
const editingUser = ref(null)
|
const editingUser = ref(null)
|
||||||
const selectedRoles = ref([])
|
const selectedRoles = ref([])
|
||||||
|
const nameSortMode = ref('firstLast')
|
||||||
|
|
||||||
const pendingUsers = computed(() => {
|
const pendingUsers = computed(() => {
|
||||||
return allUsers.value
|
return allUsers.value
|
||||||
@@ -351,6 +387,61 @@ const activeUsers = computed(() => {
|
|||||||
return allUsers.value.filter(u => u.active === true)
|
return allUsers.value.filter(u => u.active === true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const splitNameParts = (name = '') => {
|
||||||
|
const trimmed = (name || '').trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return { firstName: '', lastName: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.includes(',')) {
|
||||||
|
const [lastNameRaw, ...firstNameRaw] = trimmed.split(',')
|
||||||
|
return {
|
||||||
|
firstName: firstNameRaw.join(',').trim(),
|
||||||
|
lastName: (lastNameRaw || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
return { firstName: parts[0] || '', lastName: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstName: parts[0],
|
||||||
|
lastName: parts.slice(1).join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayName = (user) => {
|
||||||
|
const { firstName, lastName } = splitNameParts(user?.name || '')
|
||||||
|
|
||||||
|
if (nameSortMode.value === 'lastFirst') {
|
||||||
|
if (!lastName) {
|
||||||
|
return firstName
|
||||||
|
}
|
||||||
|
return `${lastName}, ${firstName}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${firstName} ${lastName}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedActiveUsers = computed(() => {
|
||||||
|
return [...activeUsers.value].sort((a, b) => {
|
||||||
|
const nameA = splitNameParts(a.name)
|
||||||
|
const nameB = splitNameParts(b.name)
|
||||||
|
|
||||||
|
if (nameSortMode.value === 'lastFirst') {
|
||||||
|
const lastNameCompare = nameA.lastName.localeCompare(nameB.lastName, 'de', { sensitivity: 'base' })
|
||||||
|
if (lastNameCompare !== 0) return lastNameCompare
|
||||||
|
return nameA.firstName.localeCompare(nameB.firstName, 'de', { sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstNameCompare = nameA.firstName.localeCompare(nameB.firstName, 'de', { sensitivity: 'base' })
|
||||||
|
if (firstNameCompare !== 0) return firstNameCompare
|
||||||
|
return nameA.lastName.localeCompare(nameB.lastName, 'de', { sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
return new Date(dateString).toLocaleString('de-DE', {
|
return new Date(dateString).toLocaleString('de-DE', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -324,6 +324,14 @@
|
|||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
|
||||||
|
<input
|
||||||
|
v-model="trainer.email"
|
||||||
|
type="email"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Zusatzinfo</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Zusatzinfo</label>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -668,6 +676,7 @@ const addTrainer = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
lizenz: '',
|
lizenz: '',
|
||||||
schwerpunkt: '',
|
schwerpunkt: '',
|
||||||
|
email: '',
|
||||||
zusatz: ''
|
zusatz: ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,54 @@
|
|||||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Geburtstage Widget -->
|
||||||
|
<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-pink-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Calendar
|
||||||
|
:size="20"
|
||||||
|
class="text-pink-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
|
Geburtstage (nächste 4 Wochen)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="loadingBirthdays"
|
||||||
|
class="text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
Lade...
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="b in birthdays"
|
||||||
|
:key="b.name + b.dayMonth"
|
||||||
|
class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 truncate">
|
||||||
|
{{ b.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ b.dayMonth }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="birthdays.length === 0"
|
||||||
|
class="text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
Keine Geburtstage in den nächsten 4 Wochen.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<!-- Inhalte (gruppiert) -->
|
<!-- Inhalte (gruppiert) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/inhalte"
|
to="/cms/inhalte"
|
||||||
@@ -90,6 +138,27 @@
|
|||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Kontaktanfragen -->
|
||||||
|
<NuxtLink
|
||||||
|
to="/cms/kontaktanfragen"
|
||||||
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||||
|
>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center group-hover:bg-emerald-600 transition-colors">
|
||||||
|
<Mail
|
||||||
|
:size="24"
|
||||||
|
class="text-emerald-600 group-hover:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
|
Kontaktanfragen
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Kontaktformular-Anfragen einsehen und beantworten
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Startseite -->
|
<!-- Startseite -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/startseite"
|
to="/cms/startseite"
|
||||||
@@ -159,10 +228,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout } from 'lucide-vue-next'
|
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail } from 'lucide-vue-next'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const birthdays = ref([])
|
||||||
|
const loadingBirthdays = ref(true)
|
||||||
|
|
||||||
|
const loadBirthdays = async () => {
|
||||||
|
loadingBirthdays.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/birthdays')
|
||||||
|
birthdays.value = res.birthdays || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Geburtstage', e)
|
||||||
|
birthdays.value = []
|
||||||
|
} finally {
|
||||||
|
loadingBirthdays.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBirthdays()
|
||||||
|
})
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
layout: 'default'
|
layout: 'default'
|
||||||
|
|||||||
@@ -2,13 +2,20 @@
|
|||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-display font-bold text-gray-900">Inhalte verwalten</h1>
|
<h1 class="text-3xl font-display font-bold text-gray-900">
|
||||||
<p class="mt-1 text-sm text-gray-500">Redaktionelle Inhalte der Website bearbeiten</p>
|
Inhalte verwalten
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Redaktionelle Inhalte der Website bearbeiten
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="border-b border-gray-200 mb-6">
|
<div class="border-b border-gray-200 mb-6">
|
||||||
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
<nav
|
||||||
|
class="-mb-px flex space-x-8 overflow-x-auto"
|
||||||
|
aria-label="Tabs"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
@@ -29,6 +36,7 @@
|
|||||||
<CmsGeschichte v-if="activeTab === 'geschichte'" />
|
<CmsGeschichte v-if="activeTab === 'geschichte'" />
|
||||||
<CmsTtRegeln v-if="activeTab === 'tt-regeln'" />
|
<CmsTtRegeln v-if="activeTab === 'tt-regeln'" />
|
||||||
<CmsSatzung v-if="activeTab === 'satzung'" />
|
<CmsSatzung v-if="activeTab === 'satzung'" />
|
||||||
|
<CmsLinks v-if="activeTab === 'links'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +48,7 @@ import CmsUeberUns from '~/components/cms/CmsUeberUns.vue'
|
|||||||
import CmsGeschichte from '~/components/cms/CmsGeschichte.vue'
|
import CmsGeschichte from '~/components/cms/CmsGeschichte.vue'
|
||||||
import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue'
|
import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue'
|
||||||
import CmsSatzung from '~/components/cms/CmsSatzung.vue'
|
import CmsSatzung from '~/components/cms/CmsSatzung.vue'
|
||||||
|
import CmsLinks from '~/components/cms/CmsLinks.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
@@ -56,6 +65,7 @@ const tabs = [
|
|||||||
{ id: 'ueber-uns', label: 'Über uns' },
|
{ id: 'ueber-uns', label: 'Über uns' },
|
||||||
{ id: 'geschichte', label: 'Geschichte' },
|
{ id: 'geschichte', label: 'Geschichte' },
|
||||||
{ id: 'tt-regeln', label: 'TT-Regeln' },
|
{ id: 'tt-regeln', label: 'TT-Regeln' },
|
||||||
{ id: 'satzung', label: 'Satzung' }
|
{ id: 'satzung', label: 'Satzung' },
|
||||||
|
{ id: 'links', label: 'Links' }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
270
pages/cms/kontaktanfragen.vue
Normal file
270
pages/cms/kontaktanfragen.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<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">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-display font-bold text-gray-900">
|
||||||
|
Kontaktanfragen
|
||||||
|
</h1>
|
||||||
|
<div class="w-24 h-1 bg-primary-600 mt-4" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="loadRequests"
|
||||||
|
>
|
||||||
|
{{ isLoading ? 'Lädt...' : 'Aktualisieren' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex items-center justify-end">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="showAnswered"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
Bearbeitete Anfragen anzeigen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="text-center py-12 text-gray-600"
|
||||||
|
>
|
||||||
|
Lade Kontaktanfragen...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="filteredRequests.length === 0"
|
||||||
|
class="bg-white rounded-xl shadow p-8 text-center text-gray-600"
|
||||||
|
>
|
||||||
|
{{ showAnswered ? 'Aktuell liegen keine Kontaktanfragen vor.' : 'Aktuell liegen keine offenen Kontaktanfragen vor.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="request in filteredRequests"
|
||||||
|
:key="request.id"
|
||||||
|
class="bg-white rounded-xl shadow border border-gray-100"
|
||||||
|
>
|
||||||
|
<div class="p-5 border-b border-gray-100 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-gray-900">
|
||||||
|
{{ request.subject }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Von {{ request.name }} ({{ request.email }}){{ request.phone ? ` · ${request.phone}` : '' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Eingegangen: {{ formatDate(request.createdAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||||
|
:class="request.status === 'beantwortet' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
|
||||||
|
>
|
||||||
|
{{ request.status === 'beantwortet' ? 'Erledigt' : 'Offen' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<p class="text-gray-800 whitespace-pre-wrap">
|
||||||
|
{{ request.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="Array.isArray(request.replies) && request.replies.length > 0"
|
||||||
|
class="mt-5 border-t border-gray-100 pt-4"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Antworten
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="reply in request.replies"
|
||||||
|
:key="reply.id"
|
||||||
|
class="bg-gray-50 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mb-1">
|
||||||
|
{{ formatDate(reply.createdAt) }}{{ reply.responderEmail ? ` · ${reply.responderEmail}` : '' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-800 whitespace-pre-wrap">
|
||||||
|
{{ reply.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||||
|
:disabled="togglingId === request.id"
|
||||||
|
@click="toggleStatus(request)"
|
||||||
|
>
|
||||||
|
{{ togglingId === request.id ? '…' : (request.status === 'beantwortet' ? 'Wieder öffnen' : 'Als erledigt markieren') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||||
|
@click="openReplyModal(request)"
|
||||||
|
>
|
||||||
|
Antworten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="replyModalOpen && selectedRequest"
|
||||||
|
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
|
@click.self="closeReplyModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Antwort senden
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
An: {{ selectedRequest.email }}<br>
|
||||||
|
Betreff: <strong>Aw: {{ selectedRequest.subject }}</strong>
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="replyText"
|
||||||
|
rows="8"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600"
|
||||||
|
placeholder="Ihre Antwort..."
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mt-3 text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||||
|
:disabled="isSendingReply"
|
||||||
|
@click="closeReplyModal"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
:disabled="isSendingReply || !replyText.trim()"
|
||||||
|
@click="sendReply"
|
||||||
|
>
|
||||||
|
{{ isSendingReply ? 'Sende...' : 'Antwort senden' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const requests = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const replyModalOpen = ref(false)
|
||||||
|
const selectedRequest = ref(null)
|
||||||
|
const replyText = ref('')
|
||||||
|
const isSendingReply = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const showAnswered = ref(false)
|
||||||
|
const togglingId = ref(null)
|
||||||
|
|
||||||
|
const filteredRequests = computed(() => {
|
||||||
|
if (showAnswered.value) return requests.value
|
||||||
|
return requests.value.filter((request) => request.status !== 'beantwortet')
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
return new Date(value).toLocaleString('de-DE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRequests = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
requests.value = await $fetch('/api/cms/contact-requests')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Kontaktanfragen:', error)
|
||||||
|
requests.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReplyModal = (request) => {
|
||||||
|
selectedRequest.value = request
|
||||||
|
replyText.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
replyModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeReplyModal = () => {
|
||||||
|
replyModalOpen.value = false
|
||||||
|
selectedRequest.value = null
|
||||||
|
replyText.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStatus = async (request) => {
|
||||||
|
togglingId.value = request.id
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/cms/contact-requests/${request.id}/toggle-status`, {
|
||||||
|
method: 'PATCH'
|
||||||
|
})
|
||||||
|
await loadRequests()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Umschalten des Status:', error)
|
||||||
|
if (window.showErrorModal) {
|
||||||
|
window.showErrorModal('Fehler', error?.data?.statusMessage || 'Status konnte nicht geändert werden.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
togglingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendReply = async () => {
|
||||||
|
if (!selectedRequest.value) return
|
||||||
|
const text = replyText.value.trim()
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
isSendingReply.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/cms/contact-requests/${selectedRequest.value.id}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { message: text }
|
||||||
|
})
|
||||||
|
closeReplyModal()
|
||||||
|
await loadRequests()
|
||||||
|
if (window.showSuccessModal) {
|
||||||
|
window.showSuccessModal('Erfolg', 'Antwort wurde erfolgreich versendet.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Senden der Antwort:', error)
|
||||||
|
errorMessage.value = error?.data?.statusMessage || error?.data?.message || 'Antwort konnte nicht gesendet werden.'
|
||||||
|
} finally {
|
||||||
|
isSendingReply.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRequests)
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Kontaktanfragen - CMS - Harheimer TC'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,15 +2,25 @@
|
|||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-display font-bold text-gray-900">Mitgliederverwaltung</h1>
|
<h1 class="text-3xl font-display font-bold text-gray-900">
|
||||||
<p class="mt-1 text-sm text-gray-500">Anträge und Mitgliederliste verwalten</p>
|
Mitgliederverwaltung
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Anträge und Mitgliederliste verwalten
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) -->
|
<!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) -->
|
||||||
<div v-show="antraegeRef?.hasApplications" class="mb-10">
|
<div
|
||||||
|
v-show="antraegeRef?.hasApplications"
|
||||||
|
class="mb-10"
|
||||||
|
>
|
||||||
<CmsMitgliedschaftsantraege ref="antraegeRef" />
|
<CmsMitgliedschaftsantraege ref="antraegeRef" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="antraegeRef?.hasApplications" class="border-t border-gray-300 mb-10" />
|
<div
|
||||||
|
v-if="antraegeRef?.hasApplications"
|
||||||
|
class="border-t border-gray-300 mb-10"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Mitgliederliste darunter -->
|
<!-- Mitgliederliste darunter -->
|
||||||
<CmsMitglieder />
|
<CmsMitglieder />
|
||||||
|
|||||||
@@ -2,13 +2,20 @@
|
|||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-display font-bold text-gray-900">Sportbetrieb verwalten</h1>
|
<h1 class="text-3xl font-display font-bold text-gray-900">
|
||||||
<p class="mt-1 text-sm text-gray-500">Termine, Mannschaften und Spielpläne pflegen</p>
|
Sportbetrieb verwalten
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Termine, Mannschaften und Spielpläne pflegen
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="border-b border-gray-200 mb-6">
|
<div class="border-b border-gray-200 mb-6">
|
||||||
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
<nav
|
||||||
|
class="-mb-px flex space-x-8 overflow-x-auto"
|
||||||
|
aria-label="Tabs"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
|
|||||||
@@ -58,7 +58,10 @@
|
|||||||
>
|
>
|
||||||
<!-- Drag Handle -->
|
<!-- Drag Handle -->
|
||||||
<div class="flex flex-col gap-1 cursor-move">
|
<div class="flex flex-col gap-1 cursor-move">
|
||||||
<GripVertical :size="16" class="text-gray-400" />
|
<GripVertical
|
||||||
|
:size="16"
|
||||||
|
class="text-gray-400"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Info -->
|
<!-- Section Info -->
|
||||||
|
|||||||
162
pages/links.vue
Normal file
162
pages/links.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full py-16 bg-gray-50">
|
||||||
|
<div class="max-w-6xl 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">
|
||||||
|
Links
|
||||||
|
</h1>
|
||||||
|
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||||
|
|
||||||
|
<p class="text-lg text-gray-600 mb-10">
|
||||||
|
Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<section
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.title"
|
||||||
|
class="bg-white rounded-xl shadow-lg p-6"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
|
||||||
|
{{ section.title }}
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="(item, idx) in section.items"
|
||||||
|
:key="`${section.title}-${idx}`"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="item.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-primary-700 hover:text-primary-900 font-medium"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
v-if="item.description"
|
||||||
|
class="text-gray-600"
|
||||||
|
> {{ item.description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const rawContent = ref('')
|
||||||
|
|
||||||
|
const defaultLinksHtml = `
|
||||||
|
<h2>Ergebnisse & Portale</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="http://www.mytischtennis.de/public/home" target="_blank" rel="noopener noreferrer">MyTischtennis.de</a> (offizielle QTTR-Werte)</li>
|
||||||
|
<li><a href="http://httv.click-tt.de/" target="_blank" rel="noopener noreferrer">Click-tt Ergebnisse</a> (offizieller Ergebnisdienst HTTV)</li>
|
||||||
|
<li><a href="https://www.tischtennis-pur.de/" target="_blank" rel="noopener noreferrer">Tischtennis Pur - das Tischtennis Portal</a> (Informationen, Blogs, Fachbeiträge, Tipps)</li>
|
||||||
|
<li><a href="https://ticker.tt-news.com/" target="_blank" rel="noopener noreferrer">Liveticker 2. und 3. TT-Bundesliga</a></li>
|
||||||
|
</ul>
|
||||||
|
<h2>Verbände</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="http://www.httv.de/" target="_blank" rel="noopener noreferrer">Hessischer Tischtennisverband (HTTV)</a></li>
|
||||||
|
<li><a href="http://www.tischtennis.de/aktuelles/" target="_blank" rel="noopener noreferrer">Deutscher Tischtennisbund (DTTB)</a></li>
|
||||||
|
<li><a href="http://www.ettu.org/" target="_blank" rel="noopener noreferrer">European Table Tennis Union (ETTU)</a></li>
|
||||||
|
<li><a href="https://www.ittf.com/" target="_blank" rel="noopener noreferrer">International Table Tennis Federation (ITTF)</a></li>
|
||||||
|
</ul>
|
||||||
|
<h2>Regionale Links</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="http://www.frankfurt.de/" target="_blank" rel="noopener noreferrer">Stadt Frankfurt</a></li>
|
||||||
|
<li><a href="http://www.harheim.com/" target="_blank" rel="noopener noreferrer">Vereinsring Harheim</a></li>
|
||||||
|
</ul>
|
||||||
|
<h2>Partner & Vereine</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="http://www.ttcoe.de/" target="_blank" rel="noopener noreferrer">TTC OE Bad Homburg</a></li>
|
||||||
|
<li><a href="https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis" target="_blank" rel="noopener noreferrer">SpVgg Steinkirchen e.V.</a></li>
|
||||||
|
<li><a href="https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/" target="_blank" rel="noopener noreferrer">Ergebnisse SpVgg Steinkirchen</a></li>
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
|
|
||||||
|
const sections = computed(() => parseLinksHtml(rawContent.value))
|
||||||
|
|
||||||
|
function stripTags(html) {
|
||||||
|
return String(html || '')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLinksHtml(html) {
|
||||||
|
const source = String(html || '')
|
||||||
|
const sectionRegex = /<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*>|$)/gi
|
||||||
|
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi
|
||||||
|
const anchorRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i
|
||||||
|
|
||||||
|
const parsed = []
|
||||||
|
let sectionMatch
|
||||||
|
while ((sectionMatch = sectionRegex.exec(source)) !== null) {
|
||||||
|
const title = stripTags(sectionMatch[1])
|
||||||
|
const body = sectionMatch[2]
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
let liMatch
|
||||||
|
while ((liMatch = liRegex.exec(body)) !== null) {
|
||||||
|
const liContent = liMatch[1]
|
||||||
|
const anchorMatch = anchorRegex.exec(liContent)
|
||||||
|
if (!anchorMatch) continue
|
||||||
|
|
||||||
|
const href = anchorMatch[1].trim()
|
||||||
|
const label = stripTags(anchorMatch[2])
|
||||||
|
const remainder = liContent.replace(anchorMatch[0], '')
|
||||||
|
const desc = stripTags(remainder)
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
description: desc || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title && items.length > 0) {
|
||||||
|
parsed.push({ title, items })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/config')
|
||||||
|
const structured = data?.seiten?.linksStructured
|
||||||
|
if (Array.isArray(structured) && structured.length > 0) {
|
||||||
|
const htmlFromStructured = structured
|
||||||
|
.filter((section) => section?.title && Array.isArray(section?.items) && section.items.length > 0)
|
||||||
|
.map((section) => {
|
||||||
|
const itemsHtml = section.items
|
||||||
|
.filter((item) => item?.label && item?.href)
|
||||||
|
.map((item) => `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${item.description ? ` ${item.description}` : ''}</li>`)
|
||||||
|
.join('')
|
||||||
|
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
rawContent.value = htmlFromStructured || defaultLinksHtml
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const links = data?.seiten?.links
|
||||||
|
rawContent.value = typeof links === 'string' && links.trim() ? links : defaultLinksHtml
|
||||||
|
} catch {
|
||||||
|
rawContent.value = defaultLinksHtml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadConfig)
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Links - Harheimer TC',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -154,7 +154,9 @@ const handleLogin = async () => {
|
|||||||
// Redirect based on role
|
// Redirect based on role
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||||
if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
if (roles.includes('trainer')) {
|
||||||
|
router.push('/cms/kontaktanfragen')
|
||||||
|
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||||
router.push('/cms')
|
router.push('/cms')
|
||||||
} else {
|
} else {
|
||||||
router.push('/mitgliederbereich')
|
router.push('/mitgliederbereich')
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
|
|
||||||
<div class="mt-3 bg-blue-50 rounded-lg p-3">
|
<div class="mt-3 bg-blue-50 rounded-lg p-3">
|
||||||
<p class="text-xs text-blue-800">
|
<p class="text-xs text-blue-800">
|
||||||
<strong>Hinweis:</strong> Ohne <code>id</code> wird ein neues Mitglied erstellt. Mit <code>id</code> wird ein bestehendes Mitglied aktualisiert. <code>geburtsdatum</code> ist Pflichtfeld zur Duplikatsprüfung (Format: YYYY-MM-DD).
|
<strong>Hinweis:</strong> Ohne <code>id</code> wird ein neues Mitglied erstellt. Mit <code>id</code> wird ein bestehendes Mitglied aktualisiert. Bei neuen Mitgliedern ist <code>geburtsdatum</code> zur Duplikatsprüfung Pflicht (Format: YYYY-MM-DD). Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,54 +71,84 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Welcome Message -->
|
<!-- Geburtstage Widget (statt Willkommens-Kachel) -->
|
||||||
<div class="bg-white p-8 rounded-xl shadow-lg border border-gray-100">
|
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
|
<div class="flex items-center mb-4">
|
||||||
Willkommen, {{ authStore.user?.name || 'Mitglied' }}!
|
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Calendar
|
||||||
|
:size="20"
|
||||||
|
class="text-pink-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
|
Geburtstage (nächste 4 Wochen)
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
Hier finden Sie alle wichtigen Informationen und Funktionen für Mitglieder des Harheimer TC.
|
|
||||||
</p>
|
|
||||||
<div class="grid sm:grid-cols-2 gap-4">
|
|
||||||
<div class="flex items-start">
|
|
||||||
<Check
|
|
||||||
:size="20"
|
|
||||||
class="text-primary-600 mr-2 mt-0.5"
|
|
||||||
/>
|
|
||||||
<span class="text-gray-700">Zugriff auf Mitgliederliste mit Kontaktdaten</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start">
|
<div
|
||||||
<Check
|
v-if="loadingBirthdays"
|
||||||
:size="20"
|
class="text-sm text-gray-500"
|
||||||
class="text-primary-600 mr-2 mt-0.5"
|
>
|
||||||
/>
|
Lade...
|
||||||
<span class="text-gray-700">Vereinsnews und Ankündigungen</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start">
|
<ul
|
||||||
<Check
|
v-else
|
||||||
:size="20"
|
class="space-y-2"
|
||||||
class="text-primary-600 mr-2 mt-0.5"
|
>
|
||||||
/>
|
<li
|
||||||
<span class="text-gray-700">Profilverwaltung und Passwort ändern</span>
|
v-for="b in birthdays"
|
||||||
|
:key="b.name + b.dayMonth"
|
||||||
|
class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 truncate">
|
||||||
|
{{ b.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start">
|
<div class="text-xs text-gray-600">
|
||||||
<Check
|
{{ b.dayMonth }}
|
||||||
:size="20"
|
|
||||||
class="text-primary-600 mr-2 mt-0.5"
|
|
||||||
/>
|
|
||||||
<span class="text-gray-700">Weitere Funktionen folgen in Kürze</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="birthdays.length === 0"
|
||||||
|
class="text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
Keine Geburtstage in den nächsten 4 Wochen.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { User, Users, Newspaper, Check } from 'lucide-vue-next'
|
import { User, Users, Newspaper, Check, Calendar } from 'lucide-vue-next'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const birthdays = ref([])
|
||||||
|
const loadingBirthdays = ref(true)
|
||||||
|
|
||||||
|
const loadBirthdays = async () => {
|
||||||
|
loadingBirthdays.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/birthdays')
|
||||||
|
birthdays.value = res.birthdays || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Geburtstage', e)
|
||||||
|
birthdays.value = []
|
||||||
|
} finally {
|
||||||
|
loadingBirthdays.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBirthdays()
|
||||||
|
})
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
layout: 'default'
|
layout: 'default'
|
||||||
|
|||||||
@@ -54,6 +54,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sortieroptionen -->
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<label
|
||||||
|
for="sortMode"
|
||||||
|
class="text-sm text-gray-700"
|
||||||
|
>Sortieren nach:</label>
|
||||||
|
<select
|
||||||
|
id="sortMode"
|
||||||
|
v-model="sortMode"
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
>
|
||||||
|
<option value="name">
|
||||||
|
Name (Vorname Nachname)
|
||||||
|
</option>
|
||||||
|
<option value="lastname">
|
||||||
|
Nachname (Nachname Vorname)
|
||||||
|
</option>
|
||||||
|
<option value="birthday">
|
||||||
|
Geburtstag
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="filterHasHallKey"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
Nur mit Hallenschlüssel
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
@@ -86,6 +119,9 @@
|
|||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Mannschaft
|
Mannschaft
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
🔑
|
||||||
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
@@ -99,13 +135,24 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<tr
|
<tr
|
||||||
v-for="member in members"
|
v-for="member in sortedMembers"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="hover:bg-gray-50"
|
class="hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<div class="text-sm font-medium text-gray-900">
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
<template v-if="member.lastName || member.firstName">
|
||||||
|
{{ member.firstName }} {{ member.lastName }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="member.birthday"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
🎂 {{ formatBirthday(member.birthday) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="member.notes"
|
v-if="member.notes"
|
||||||
@@ -115,42 +162,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<template v-if="canViewContactData">
|
<template v-if="member.showEmail && member.email">
|
||||||
<a
|
<a
|
||||||
v-if="member.email"
|
|
||||||
:href="`mailto:${member.email}`"
|
:href="`mailto:${member.email}`"
|
||||||
class="text-sm text-primary-600 hover:text-primary-800"
|
class="text-sm text-primary-600 hover:text-primary-800"
|
||||||
>
|
>
|
||||||
{{ member.email }}
|
{{ member.email }}
|
||||||
</a>
|
</a>
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-sm text-gray-400"
|
|
||||||
>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<span
|
<template v-else>
|
||||||
v-else
|
<span class="text-sm text-gray-400">Kontaktdaten nur für Vorstand sichtbar</span>
|
||||||
class="text-sm text-gray-400"
|
</template>
|
||||||
>Nur für Vorstand</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<template v-if="canViewContactData">
|
<template v-if="member.showPhone && member.phone">
|
||||||
<a
|
<a
|
||||||
v-if="member.phone"
|
|
||||||
:href="`tel:${member.phone}`"
|
:href="`tel:${member.phone}`"
|
||||||
class="text-sm text-primary-600 hover:text-primary-800"
|
class="text-sm text-primary-600 hover:text-primary-800"
|
||||||
>
|
>
|
||||||
{{ member.phone }}
|
{{ member.phone }}
|
||||||
</a>
|
</a>
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-sm text-gray-400"
|
|
||||||
>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<span
|
<template v-else>
|
||||||
v-else
|
<span class="text-sm text-gray-400">Kontaktdaten nur für Vorstand sichtbar</span>
|
||||||
class="text-sm text-gray-400"
|
</template>
|
||||||
>Nur für Vorstand</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
@@ -178,6 +213,15 @@
|
|||||||
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
v-if="member.hasHallKey"
|
||||||
|
class="text-lg text-amber-600"
|
||||||
|
title="Hat Hallenschlüssel"
|
||||||
|
>
|
||||||
|
🔑
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span
|
<span
|
||||||
@@ -228,7 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="members.length === 0"
|
v-if="sortedMembers.length === 0"
|
||||||
class="text-center py-12 text-gray-500"
|
class="text-center py-12 text-gray-500"
|
||||||
>
|
>
|
||||||
Keine Mitglieder gefunden.
|
Keine Mitglieder gefunden.
|
||||||
@@ -241,7 +285,7 @@
|
|||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="member in members"
|
v-for="member in sortedMembers"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
|
||||||
>
|
>
|
||||||
@@ -249,8 +293,26 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<h3 class="text-xl font-semibold text-gray-900">
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
<template v-if="member.lastName || member.firstName">
|
||||||
|
{{ member.firstName }} {{ member.lastName }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="member.birthday"
|
||||||
|
class="text-xs text-gray-500 ml-2"
|
||||||
|
>
|
||||||
|
🎂 {{ formatBirthday(member.birthday) }}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="member.hasHallKey"
|
||||||
|
class="ml-2 text-amber-600"
|
||||||
|
title="Hat Hallenschlüssel"
|
||||||
|
>
|
||||||
|
🔑
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="member.hasLogin"
|
v-if="member.hasLogin"
|
||||||
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
|
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
|
||||||
@@ -296,9 +358,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
|
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
|
||||||
<template v-if="canViewContactData">
|
<template v-if="!(member.showEmail && member.email) && !(member.showPhone && member.phone)">
|
||||||
|
<div class="col-span-2 flex items-center text-gray-500 text-sm italic">
|
||||||
|
<Mail
|
||||||
|
:size="16"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
Kontaktdaten nur für Vorstand sichtbar
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-if="member.email"
|
v-if="member.showEmail && member.email"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<Mail
|
<Mail
|
||||||
@@ -311,7 +382,7 @@
|
|||||||
>{{ member.email }}</a>
|
>{{ member.email }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="member.phone"
|
v-if="member.showPhone && member.phone"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<Phone
|
<Phone
|
||||||
@@ -324,15 +395,44 @@
|
|||||||
>{{ member.phone }}</a>
|
>{{ member.phone }}</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<!-- Sichtbarkeits-Flags anzeigen -->
|
||||||
|
<div class="col-span-2 flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||||
|
<span
|
||||||
|
v-if="member.showEmail"
|
||||||
|
title="E-Mail sichtbar"
|
||||||
|
>📧</span>
|
||||||
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="col-span-2 flex items-center text-gray-500 text-sm italic"
|
title="E-Mail verborgen"
|
||||||
>
|
class="opacity-40"
|
||||||
<Mail
|
>📧</span>
|
||||||
:size="16"
|
<span
|
||||||
class="mr-2"
|
v-if="member.showPhone"
|
||||||
/>
|
title="Telefon sichtbar"
|
||||||
Kontaktdaten nur für Vorstand sichtbar
|
>📞</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
title="Telefon verborgen"
|
||||||
|
class="opacity-40"
|
||||||
|
>📞</span>
|
||||||
|
<span
|
||||||
|
v-if="member.showAddress"
|
||||||
|
title="Adresse sichtbar"
|
||||||
|
>🏠</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
title="Adresse verborgen"
|
||||||
|
class="opacity-40"
|
||||||
|
>🏠</span>
|
||||||
|
<span
|
||||||
|
v-if="member.showBirthday"
|
||||||
|
title="Geburtstag sichtbar"
|
||||||
|
>🎂</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
title="Geburtstag verborgen"
|
||||||
|
class="opacity-40"
|
||||||
|
>🎂</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="member.address"
|
v-if="member.address"
|
||||||
@@ -390,7 +490,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="members.length === 0"
|
v-if="sortedMembers.length === 0"
|
||||||
class="text-center py-12 text-gray-500"
|
class="text-center py-12 text-gray-500"
|
||||||
>
|
>
|
||||||
Keine Mitglieder gefunden.
|
Keine Mitglieder gefunden.
|
||||||
@@ -440,12 +540,12 @@
|
|||||||
<input
|
<input
|
||||||
v-model="formData.geburtsdatum"
|
v-model="formData.geburtsdatum"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
:required="isBirthdateRequired"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Wird zur eindeutigen Identifizierung benötigt
|
Für neue Mitglieder erforderlich. Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -505,6 +605,22 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="hasHallKey"
|
||||||
|
v-model="formData.hasHallKey"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="hasHallKey"
|
||||||
|
class="ml-2 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Hat Hallenschlüssel
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||||
@@ -770,6 +886,82 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
const sortMode = ref('name')
|
||||||
|
const filterHasHallKey = ref(false)
|
||||||
|
|
||||||
|
const sortedMembers = computed(() => {
|
||||||
|
if (!Array.isArray(members.value)) return []
|
||||||
|
const arr = filterHasHallKey.value
|
||||||
|
? members.value.filter(member => member.hasHallKey)
|
||||||
|
: [...members.value]
|
||||||
|
if (sortMode.value === 'name') {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
// Sortiere nach Vorname Nachname (firstName lastName)
|
||||||
|
const af = (a.firstName || '').toLocaleLowerCase()
|
||||||
|
const bf = (b.firstName || '').toLocaleLowerCase()
|
||||||
|
const al = (a.lastName || '').toLocaleLowerCase()
|
||||||
|
const bl = (b.lastName || '').toLocaleLowerCase()
|
||||||
|
if (af === bf) return al.localeCompare(bl)
|
||||||
|
return af.localeCompare(bf)
|
||||||
|
})
|
||||||
|
} else if (sortMode.value === 'lastname') {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
// Sortiere nach Nachname, dann Vorname
|
||||||
|
const al = (a.lastName || '').toLocaleLowerCase()
|
||||||
|
const bl = (b.lastName || '').toLocaleLowerCase()
|
||||||
|
if (al === bl) {
|
||||||
|
const af = (a.firstName || '').toLocaleLowerCase()
|
||||||
|
const bf = (b.firstName || '').toLocaleLowerCase()
|
||||||
|
return af.localeCompare(bf)
|
||||||
|
}
|
||||||
|
return al.localeCompare(bl)
|
||||||
|
})
|
||||||
|
} else if (sortMode.value === 'birthday') {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
// Robust: akzeptiere YYYY-MM-DD, DD.MM.YYYY, ggf. nur MM-TT
|
||||||
|
function parseBirthday(val) {
|
||||||
|
if (!val) return null
|
||||||
|
if (val.includes('-')) {
|
||||||
|
const parts = val.split('-')
|
||||||
|
if (parts.length === 3) return { m: parts[1].padStart(2, '0'), d: parts[2].padStart(2, '0') }
|
||||||
|
} else if (val.includes('.')) {
|
||||||
|
const parts = val.split('.')
|
||||||
|
if (parts.length >= 2) return { d: parts[0].padStart(2, '0'), m: parts[1].padStart(2, '0') }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const ad = parseBirthday(a.birthday)
|
||||||
|
const bd = parseBirthday(b.birthday)
|
||||||
|
if (!ad && !bd) return 0
|
||||||
|
if (!ad) return 1
|
||||||
|
if (!bd) return -1
|
||||||
|
// Monat zuerst, dann Tag
|
||||||
|
if (ad.m === bd.m) return ad.d.localeCompare(bd.d)
|
||||||
|
return ad.m.localeCompare(bd.m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatBirthday(dateStr) {
|
||||||
|
// Erwartet YYYY-MM-DD oder DD.MM.YYYY
|
||||||
|
if (!dateStr) return ''
|
||||||
|
if (dateStr.includes('-')) {
|
||||||
|
const [, m, d] = dateStr.split('-')
|
||||||
|
return `${d}.${m}.`
|
||||||
|
} else if (dateStr.includes('.')) {
|
||||||
|
const parts = dateStr.split('.')
|
||||||
|
if (parts.length >= 2) return `${parts[0]}.${parts[1]}.`
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// members muss showBirthday und birthday enthalten:
|
||||||
|
// showBirthday: true, wenn das Mitglied die Anzeige erlaubt
|
||||||
|
// birthday: im Format YYYY-MM-DD oder DD.MM.YYYY
|
||||||
|
// Falls die Datenstruktur anders ist, bitte anpassen!
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'
|
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -800,7 +992,8 @@ const formData = ref({
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isMannschaftsspieler: false
|
isMannschaftsspieler: false,
|
||||||
|
hasHallKey: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => {
|
||||||
@@ -812,6 +1005,10 @@ const canViewContactData = computed(() => {
|
|||||||
return authStore.hasRole('vorstand')
|
return authStore.hasRole('vorstand')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isBirthdateRequired = computed(() => {
|
||||||
|
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
||||||
|
})
|
||||||
|
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -834,7 +1031,8 @@ const openAddModal = () => {
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isMannschaftsspieler: false
|
isMannschaftsspieler: false,
|
||||||
|
hasHallKey: false
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -850,7 +1048,8 @@ const openEditModal = (member) => {
|
|||||||
phone: member.phone || '',
|
phone: member.phone || '',
|
||||||
address: member.address || '',
|
address: member.address || '',
|
||||||
notes: member.notes || '',
|
notes: member.notes || '',
|
||||||
isMannschaftsspieler: member.isMannschaftsspieler === true
|
isMannschaftsspieler: member.isMannschaftsspieler === true,
|
||||||
|
hasHallKey: member.hasHallKey === true
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|||||||
@@ -77,6 +77,70 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="geburtsdatum"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
Geburtsdatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="geburtsdatum"
|
||||||
|
v-model="formData.geburtsdatum"
|
||||||
|
type="date"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Sichtbar ist in der Mitgliederliste nur der Geburtstag, wenn Sie ihn unten freigeben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sichtbarkeits-Einstellungen -->
|
||||||
|
<div class="mt-4 border-t border-gray-100 pt-4">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
Sichtbarkeit für andere Mitglieder
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-gray-700">
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="visibility.showEmail"
|
||||||
|
type="checkbox"
|
||||||
|
class="mr-2"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
E-Mail für alle eingeloggten Mitglieder sichtbar
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="visibility.showPhone"
|
||||||
|
type="checkbox"
|
||||||
|
class="mr-2"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Telefonnummer für alle eingeloggten Mitglieder sichtbar
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="visibility.showAddress"
|
||||||
|
type="checkbox"
|
||||||
|
class="mr-2"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Adresse für alle eingeloggten Mitglieder sichtbar
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="visibility.showBirthday"
|
||||||
|
type="checkbox"
|
||||||
|
class="mr-2"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Geburtstag für alle eingeloggten Mitglieder sichtbar
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Passwort ändern -->
|
<!-- Passwort ändern -->
|
||||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
@@ -276,7 +340,16 @@ if (process.client) {
|
|||||||
const formData = ref({
|
const formData = ref({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: ''
|
phone: '',
|
||||||
|
geburtsdatum: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Visibility preferences for other logged-in members
|
||||||
|
const visibility = ref({
|
||||||
|
showEmail: true,
|
||||||
|
showPhone: true,
|
||||||
|
showAddress: false,
|
||||||
|
showBirthday: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const passwordData = ref({
|
const passwordData = ref({
|
||||||
@@ -295,8 +368,10 @@ const loadProfile = async () => {
|
|||||||
formData.value = {
|
formData.value = {
|
||||||
name: response.user.name,
|
name: response.user.name,
|
||||||
email: response.user.email,
|
email: response.user.email,
|
||||||
phone: response.user.phone || ''
|
phone: response.user.phone || '',
|
||||||
|
geburtsdatum: response.user.geburtsdatum || ''
|
||||||
}
|
}
|
||||||
|
visibility.value = response.user.visibility || visibility.value
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage.value = 'Fehler beim Laden des Profils.'
|
errorMessage.value = 'Fehler beim Laden des Profils.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -398,6 +473,8 @@ const handleSave = async () => {
|
|||||||
name: formData.value.name,
|
name: formData.value.name,
|
||||||
email: formData.value.email,
|
email: formData.value.email,
|
||||||
phone: formData.value.phone,
|
phone: formData.value.phone,
|
||||||
|
geburtsdatum: formData.value.geburtsdatum,
|
||||||
|
visibility: visibility.value,
|
||||||
currentPassword: passwordData.value.current || undefined,
|
currentPassword: passwordData.value.current || undefined,
|
||||||
newPassword: passwordData.value.new || undefined
|
newPassword: passwordData.value.new || undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,22 +32,40 @@
|
|||||||
</div>
|
</div>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- Name -->
|
<!-- Vorname -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="name"
|
for="firstName"
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
>
|
>
|
||||||
Vollständiger Name
|
Vorname
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="firstName"
|
||||||
v-model="formData.name"
|
v-model="formData.firstName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autocomplete="name"
|
autocomplete="given-name"
|
||||||
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="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
|
||||||
placeholder="Max Mustermann"
|
placeholder="Max"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- Nachname -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="lastName"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
Nachname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
v-model="formData.lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="family-name"
|
||||||
|
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"
|
||||||
|
placeholder="Mustermann"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,6 +106,31 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="geburtsdatum"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
Geburtsdatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="geburtsdatum"
|
||||||
|
v-model="formData.geburtsdatum"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="formData.hideBirthday"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-600 border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
Geburtsdatum in der Mitgliederliste nicht anzeigen
|
||||||
|
</label>
|
||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
@@ -130,8 +173,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Optional password toggle for passkey users - vorläufig deaktiviert -->
|
<!-- Optional password toggle for passkey users - vorläufig deaktiviert -->
|
||||||
<!--
|
<div
|
||||||
<div v-if="usePasskey" class="flex items-center gap-2 text-sm text-gray-700">
|
v-if="false"
|
||||||
|
class="flex items-center gap-2 text-sm text-gray-700"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
v-model="setPasswordForPasskey"
|
v-model="setPasswordForPasskey"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -174,17 +219,23 @@
|
|||||||
v-if="false"
|
v-if="false"
|
||||||
class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-xs space-y-3"
|
class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-xs space-y-3"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-blue-900 mb-2">🔍 Debug-Informationen (QR-Code):</div>
|
<div class="font-semibold text-blue-900 mb-2">
|
||||||
|
🔍 Debug-Informationen (QR-Code):
|
||||||
|
</div>
|
||||||
<div class="space-y-1 text-blue-800">
|
<div class="space-y-1 text-blue-800">
|
||||||
<div><strong>Challenge:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugChallenge }}</code></div>
|
<div><strong>Challenge:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugChallenge }}</code></div>
|
||||||
<div><strong>RP-ID:</strong> <code class="bg-blue-100 px-1 rounded">{{ debugRpId }}</code></div>
|
<div><strong>RP-ID:</strong> <code class="bg-blue-100 px-1 rounded">{{ debugRpId }}</code></div>
|
||||||
<div v-if="debugRegistrationId"><strong>Registration-ID:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugRegistrationId }}</code></div>
|
<div v-if="debugRegistrationId">
|
||||||
|
<strong>Registration-ID:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugRegistrationId }}</code>
|
||||||
|
</div>
|
||||||
<div><strong>Origin:</strong> <code class="bg-blue-100 px-1 rounded">{{ typeof window !== 'undefined' ? window.location.origin : 'N/A (SSR)' }}</code></div>
|
<div><strong>Origin:</strong> <code class="bg-blue-100 px-1 rounded">{{ typeof window !== 'undefined' ? window.location.origin : 'N/A (SSR)' }}</code></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FIDO QR-Code Info -->
|
<!-- FIDO QR-Code Info -->
|
||||||
<div class="mt-3 p-3 bg-purple-50 border border-purple-300 rounded">
|
<div class="mt-3 p-3 bg-purple-50 border border-purple-300 rounded">
|
||||||
<div class="font-semibold text-purple-900 mb-2">🔐 FIDO Cross-Device Info:</div>
|
<div class="font-semibold text-purple-900 mb-2">
|
||||||
|
🔐 FIDO Cross-Device Info:
|
||||||
|
</div>
|
||||||
<div class="text-xs text-purple-800 space-y-2">
|
<div class="text-xs text-purple-800 space-y-2">
|
||||||
<div><strong>QR-Code-Format:</strong> FIDO-URI (enthält öffentlichen Schlüssel + Secret)</div>
|
<div><strong>QR-Code-Format:</strong> FIDO-URI (enthält öffentlichen Schlüssel + Secret)</div>
|
||||||
<div><strong>Hinweis:</strong> Der QR-Code enthält einen FIDO-URI, der vom Smartphone gescannt werden muss.</div>
|
<div><strong>Hinweis:</strong> Der QR-Code enthält einen FIDO-URI, der vom Smartphone gescannt werden muss.</div>
|
||||||
@@ -227,10 +278,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Smartphone URL -->
|
<!-- Smartphone URL -->
|
||||||
<div v-if="debugSmartphoneUrl" class="mt-3 p-3 bg-green-50 border border-green-300 rounded">
|
<div
|
||||||
<div class="font-semibold text-green-900 mb-2">📱 Alternative: Smartphone-URL (manuell öffnen):</div>
|
v-if="debugSmartphoneUrl"
|
||||||
|
class="mt-3 p-3 bg-green-50 border border-green-300 rounded"
|
||||||
|
>
|
||||||
|
<div class="font-semibold text-green-900 mb-2">
|
||||||
|
📱 Alternative: Smartphone-URL (manuell öffnen):
|
||||||
|
</div>
|
||||||
<div class="break-all text-xs mb-2 p-2 bg-white rounded border">
|
<div class="break-all text-xs mb-2 p-2 bg-white rounded border">
|
||||||
<a :href="debugSmartphoneUrl" target="_blank" class="text-blue-600 hover:underline">
|
<a
|
||||||
|
:href="debugSmartphoneUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
{{ debugSmartphoneUrl }}
|
{{ debugSmartphoneUrl }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,23 +298,26 @@
|
|||||||
<strong>Anleitung:</strong> Falls der QR-Code nicht funktioniert, öffnen Sie diese URL manuell auf Ihrem Smartphone.
|
<strong>Anleitung:</strong> Falls der QR-Code nicht funktioniert, öffnen Sie diese URL manuell auf Ihrem Smartphone.
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(debugSmartphoneUrl)"
|
|
||||||
class="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
|
class="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
|
||||||
|
@click="copyToClipboard(debugSmartphoneUrl)"
|
||||||
>
|
>
|
||||||
📋 URL kopieren
|
📋 URL kopieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Full Options JSON -->
|
<!-- Full Options JSON -->
|
||||||
<div v-if="debugOptions" class="mt-3">
|
<div
|
||||||
|
v-if="debugOptions"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
<details class="text-xs">
|
<details class="text-xs">
|
||||||
<summary class="cursor-pointer font-semibold text-blue-900 hover:text-blue-700 mb-2">
|
<summary class="cursor-pointer font-semibold text-blue-900 hover:text-blue-700 mb-2">
|
||||||
📄 Vollständige Options (JSON) - Klicken zum Anzeigen
|
📄 Vollständige Options (JSON) - Klicken zum Anzeigen
|
||||||
</summary>
|
</summary>
|
||||||
<pre class="mt-2 p-2 bg-gray-100 rounded overflow-auto text-xs max-h-60 border">{{ JSON.stringify(debugOptions, null, 2) }}</pre>
|
<pre class="mt-2 p-2 bg-gray-100 rounded overflow-auto text-xs max-h-60 border">{{ JSON.stringify(debugOptions, null, 2) }}</pre>
|
||||||
<button
|
<button
|
||||||
@click="copyToClipboard(JSON.stringify(debugOptions, null, 2))"
|
|
||||||
class="mt-2 px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700"
|
class="mt-2 px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700"
|
||||||
|
@click="copyToClipboard(JSON.stringify(debugOptions, null, 2))"
|
||||||
>
|
>
|
||||||
📋 JSON kopieren
|
📋 JSON kopieren
|
||||||
</button>
|
</button>
|
||||||
@@ -318,9 +381,12 @@ import { AlertCircle, Check, Loader2, Info } from 'lucide-vue-next'
|
|||||||
// console.log('[DEBUG] Component setup started')
|
// console.log('[DEBUG] Component setup started')
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
name: '',
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
geburtsdatum: '',
|
||||||
|
hideBirthday: false,
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
})
|
})
|
||||||
@@ -400,9 +466,15 @@ const handleRegister = async () => {
|
|||||||
const response = await $fetch('/api/auth/register', {
|
const response = await $fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
name: formData.value.name,
|
firstName: formData.value.firstName,
|
||||||
|
lastName: formData.value.lastName,
|
||||||
|
name: `${formData.value.firstName} ${formData.value.lastName}`.trim(),
|
||||||
email: formData.value.email,
|
email: formData.value.email,
|
||||||
phone: formData.value.phone,
|
phone: formData.value.phone,
|
||||||
|
geburtsdatum: formData.value.geburtsdatum,
|
||||||
|
visibility: {
|
||||||
|
showBirthday: !formData.value.hideBirthday
|
||||||
|
},
|
||||||
password: formData.value.password
|
password: formData.value.password
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -412,9 +484,12 @@ const handleRegister = async () => {
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
formData.value = {
|
formData.value = {
|
||||||
name: '',
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
geburtsdatum: '',
|
||||||
|
hideBirthday: false,
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
25
scripts/README-split-names.md
Normal file
25
scripts/README-split-names.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
Split-Name Scripts
|
||||||
|
|
||||||
|
Diese Scripts helfen, das Feld `name` in `firstName` und `lastName` zu splitten, für verschiedene Datenquellen im Projekt.
|
||||||
|
|
||||||
|
Available scripts:
|
||||||
|
|
||||||
|
- `scripts/split-names-in-users.js` (CommonJS)
|
||||||
|
- Splittet `server/data/users.json` und ergänzt fehlende `firstName`/`lastName`.
|
||||||
|
- Erstellt ein Backup `users.json.bak.<timestamp>` falls Änderungen gemacht werden.
|
||||||
|
- Ausführen: `node scripts/split-names-in-users.js`
|
||||||
|
|
||||||
|
- `scripts/split-names-in-members.js` (ESM)
|
||||||
|
- Liest `members.json` über `server/utils/members.js` (beachtet Verschlüsselung), führt Dry-Run by default.
|
||||||
|
- Mit `--apply` werden Änderungen geschrieben und ein Backup erstellt.
|
||||||
|
- Ausführen (dry-run): `node scripts/split-names-in-members.js`
|
||||||
|
- Ausführen (apply): `node scripts/split-names-in-members.js --apply`
|
||||||
|
|
||||||
|
- `scripts/split-names-in-membership-apps.js` (CommonJS)
|
||||||
|
- Bearbeitet alle JSON-Dateien in `server/data/membership-applications/` und erstellt `.bak` Backups pro Datei.
|
||||||
|
- Ausführen: `node scripts/split-names-in-membership-apps.js`
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
- Die Scripts sind vorsichtig: sie erstellen Backups bevor sie schreiben (außer beim Dry-Run für members.js).
|
||||||
|
- `split-names-in-members.js` nutzt die vorhandenen `readMembers`/`writeMembers` Utilities, um Verschlüsselung zu respektieren.
|
||||||
|
- Teste zuerst mit DRY-RUN oder in einer Kopie des Datenverzeichnisses.
|
||||||
81
scripts/add-vorstand-role.js
Normal file
81
scripts/add-vorstand-role.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Gibt einem bestehenden Benutzer zusaetzlich die Rolle "vorstand".
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node scripts/add-vorstand-role.js
|
||||||
|
* node scripts/add-vorstand-role.js <email>
|
||||||
|
*
|
||||||
|
* Standard-E-Mail:
|
||||||
|
* tsschulz@gmx.net
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import { readUsers, writeUsers, migrateUserRoles } from '../server/utils/auth.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
dotenv.config({ path: path.join(__dirname, '..', '.env') })
|
||||||
|
|
||||||
|
const targetEmail = String(process.argv[2] || 'tsschulz@gmx.net').trim().toLowerCase()
|
||||||
|
|
||||||
|
function getUsersFilePath() {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return `${cwd}/../server/data/users.json`
|
||||||
|
}
|
||||||
|
return `${cwd}/server/data/users.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBackup(filePath) {
|
||||||
|
const backupDir = path.join(__dirname, '..', 'backups', `users-${Date.now()}`)
|
||||||
|
await fs.mkdir(backupDir, { recursive: true })
|
||||||
|
const backupPath = path.join(backupDir, 'users.json')
|
||||||
|
await fs.copyFile(filePath, backupPath)
|
||||||
|
return backupPath
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const usersFile = getUsersFilePath()
|
||||||
|
|
||||||
|
console.log(`Suche Benutzer: ${targetEmail}`)
|
||||||
|
|
||||||
|
const users = await readUsers()
|
||||||
|
const user = users.find((entry) => String(entry.email || '').trim().toLowerCase() === targetEmail)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`Benutzer nicht gefunden: ${targetEmail}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateUserRoles(user)
|
||||||
|
const currentRoles = Array.isArray(user.roles) ? [...user.roles] : []
|
||||||
|
|
||||||
|
if (currentRoles.includes('vorstand')) {
|
||||||
|
console.log(`Benutzer ${targetEmail} hat die Rolle "vorstand" bereits.`)
|
||||||
|
console.log(`Aktuelle Rollen: ${currentRoles.join(', ')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupPath = await createBackup(usersFile)
|
||||||
|
user.roles = [...new Set([...currentRoles, 'vorstand'])]
|
||||||
|
|
||||||
|
const success = await writeUsers(users)
|
||||||
|
if (!success) {
|
||||||
|
console.error('Fehler beim Schreiben der Benutzerdaten.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Backup erstellt: ${backupPath}`)
|
||||||
|
console.log(`Rolle "vorstand" hinzugefuegt fuer ${targetEmail}`)
|
||||||
|
console.log(`Aktuelle Rollen: ${user.roles.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Fehler beim Hinzufuegen der Rolle "vorstand":', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
25
scripts/check-package-version-changed.sh
Executable file
25
scripts/check-package-version-changed.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_REF="${1:-origin/main}"
|
||||||
|
BASE_BRANCH="${BASE_REF#origin/}"
|
||||||
|
|
||||||
|
git fetch --no-tags --depth=1 origin "$BASE_BRANCH"
|
||||||
|
|
||||||
|
current_version="$(node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); process.stdout.write(String(pkg.version || ""));')"
|
||||||
|
base_version="$(git show "$BASE_REF:package.json" | node -e 'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const pkg = JSON.parse(input); process.stdout.write(String(pkg.version || "")); });')"
|
||||||
|
|
||||||
|
if [ -z "$current_version" ]; then
|
||||||
|
echo "ERROR: package.json enthält kein version-Feld."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_version" = "$base_version" ]; then
|
||||||
|
echo "ERROR: package.json version wurde nicht geändert."
|
||||||
|
echo "Base ($BASE_REF): $base_version"
|
||||||
|
echo "Current: $current_version"
|
||||||
|
echo "Bitte version in package.json erhöhen, bevor nach main gemerged wird."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "package.json version changed: $base_version -> $current_version"
|
||||||
80
scripts/check-visibility.js
Normal file
80
scripts/check-visibility.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { readMembers } = await import('../server/utils/members.js')
|
||||||
|
const auth = await import('../server/utils/auth.js')
|
||||||
|
const { readUsers } = auth
|
||||||
|
|
||||||
|
const manual = await readMembers()
|
||||||
|
const users = await readUsers()
|
||||||
|
|
||||||
|
// Build simple merged list similar to members.get
|
||||||
|
const merged = []
|
||||||
|
|
||||||
|
// Add manual members
|
||||||
|
for (const m of manual) {
|
||||||
|
const fullName = `${m.firstName || ''} ${m.lastName || ''}`.trim()
|
||||||
|
const vis = m.visibility || {}
|
||||||
|
const visibility = {
|
||||||
|
showEmail: vis.showEmail === undefined ? false : Boolean(vis.showEmail),
|
||||||
|
showPhone: vis.showPhone === undefined ? false : Boolean(vis.showPhone),
|
||||||
|
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
|
||||||
|
}
|
||||||
|
merged.push({
|
||||||
|
id: m.id || null,
|
||||||
|
name: fullName || m.name || '(kein name)',
|
||||||
|
email: m.email || '',
|
||||||
|
phone: m.phone || '',
|
||||||
|
address: m.address || '',
|
||||||
|
source: 'manual',
|
||||||
|
visibility,
|
||||||
|
raw: m
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add registered users (default visibility: false unless stored)
|
||||||
|
for (const u of users) {
|
||||||
|
if (!u.active) continue
|
||||||
|
const visibility = u.visibility || { showEmail: false, showPhone: false, showAddress: false }
|
||||||
|
merged.push({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email || '',
|
||||||
|
phone: u.phone || '',
|
||||||
|
address: u.address || '',
|
||||||
|
source: 'login',
|
||||||
|
visibility,
|
||||||
|
raw: u
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
const viewers = [
|
||||||
|
{ label: 'unauthenticated', isPrivileged: false },
|
||||||
|
{ label: 'admin', isPrivileged: false },
|
||||||
|
{ label: 'vorstand', isPrivileged: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const v of viewers) {
|
||||||
|
console.log('\n=== Viewer:', v.label, ' (vorstand override:', v.isPrivileged, ') ===')
|
||||||
|
for (const m of merged) {
|
||||||
|
const hadEmail = !!m.email
|
||||||
|
const hadPhone = !!m.phone
|
||||||
|
const showEmail = v.isPrivileged || Boolean(m.visibility.showEmail)
|
||||||
|
const showPhone = v.isPrivileged || Boolean(m.visibility.showPhone)
|
||||||
|
const contactHidden = (!showEmail && hadEmail) || (!showPhone && hadPhone)
|
||||||
|
console.log(`- ${m.name}`)
|
||||||
|
console.log(` source: ${m.source} roles?: ${m.raw.roles || m.raw.role || ''}`)
|
||||||
|
console.log(` email: ${hadEmail ? (showEmail ? m.email : '<HIDDEN>') : '-'}`)
|
||||||
|
console.log(` phone: ${hadPhone ? (showPhone ? m.phone : '<HIDDEN>') : '-'}`)
|
||||||
|
if (contactHidden) console.log(' -> contactHidden = true')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ERROR', e)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
})()
|
||||||
22
scripts/diagnose-members-visibility.js
Normal file
22
scripts/diagnose-members-visibility.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Diagnose-Skript: Zeigt alle Mitglieder aus members.json mit Status und Sichtbarkeit
|
||||||
|
import { readMembers } from '../server/utils/members.js'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const members = await readMembers()
|
||||||
|
if (!members || members.length === 0) {
|
||||||
|
console.log('Keine Mitglieder geladen (members.json leer oder nicht entschlüsselbar)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const m of members) {
|
||||||
|
const status = m.active === true ? 'active' : (m.status ? m.status : 'inactive')
|
||||||
|
const vis = m.visibility || {}
|
||||||
|
console.log(`ID: ${m.id || '-'} | Name: ${m.firstName || ''} ${m.lastName || ''}`)
|
||||||
|
console.log(` Status: ${status}`)
|
||||||
|
console.log(` Email: ${m.email || '-'} | Phone: ${m.phone || '-'}`)
|
||||||
|
console.log(` Sichtbarkeit:`, vis)
|
||||||
|
console.log('---')
|
||||||
|
}
|
||||||
|
console.log(`Insgesamt: ${members.length} Mitglieder geladen.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -2,14 +2,14 @@ import fs from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
|
||||||
const getDataPath = (filename) => {
|
const getDataRoot = () => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
|
return cwd.endsWith('.output') ? `${cwd}/../server/data` : `${cwd}/server/data`
|
||||||
return path.join(cwd, 'server/data', filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GALERIE_DIR = getDataPath('galerie')
|
const DATA_ROOT = getDataRoot()
|
||||||
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
const GALERIE_DIR = `${DATA_ROOT}/galerie`
|
||||||
|
const GALERIE_METADATA = `${DATA_ROOT}/galerie-metadata.json`
|
||||||
|
|
||||||
async function readJsonArray(file) {
|
async function readJsonArray(file) {
|
||||||
try {
|
try {
|
||||||
@@ -41,14 +41,16 @@ async function fileExists(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generatePreviewForEntry(entry, size) {
|
async function generatePreviewForEntry(entry, size) {
|
||||||
const original = path.join(GALERIE_DIR, 'originals', entry.filename)
|
const safeOriginal = path.basename(String(entry.filename || ''))
|
||||||
|
const original = `${GALERIE_DIR}/originals/${safeOriginal}`
|
||||||
if (!(await fileExists(original))) return { ok: false, reason: 'missing original' }
|
if (!(await fileExists(original))) return { ok: false, reason: 'missing original' }
|
||||||
|
|
||||||
const previewFilename = entry.previewFilename && String(entry.previewFilename).trim() !== ''
|
const previewFilename = entry.previewFilename && String(entry.previewFilename).trim() !== ''
|
||||||
? entry.previewFilename
|
? entry.previewFilename
|
||||||
: `preview_${entry.filename}`
|
: `preview_${entry.filename}`
|
||||||
|
|
||||||
const preview = path.join(GALERIE_DIR, 'previews', previewFilename)
|
const safePreview = path.basename(String(previewFilename || ''))
|
||||||
|
const preview = `${GALERIE_DIR}/previews/${safePreview}`
|
||||||
|
|
||||||
await sharp(original)
|
await sharp(original)
|
||||||
.rotate()
|
.rotate()
|
||||||
|
|||||||
@@ -60,25 +60,35 @@ async function inspect(pdfPath) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const repoRoot = process.cwd()
|
const repoRoot = process.cwd()
|
||||||
const template = path.join(repoRoot, 'server', 'templates', 'mitgliedschaft-fillable.pdf')
|
const template = path.join(repoRoot, 'server', 'templates', 'mitgliedschaft-fillable.pdf')
|
||||||
// pick latest generated PDF in public/uploads that is not the sample
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// Prefer internal upload directory used by the API (server/data/uploads).
|
||||||
const uploads = path.join(repoRoot, 'public', 'uploads')
|
// If legacy files exist in public/uploads, warn and inspect them as well.
|
||||||
|
const internalUploads = path.join(repoRoot, 'server', 'data', 'uploads')
|
||||||
|
const publicUploads = path.join(repoRoot, 'public', 'uploads')
|
||||||
|
|
||||||
let pdfFiles = []
|
let pdfFiles = []
|
||||||
if (fs.existsSync(uploads)) {
|
if (fs.existsSync(internalUploads)) {
|
||||||
pdfFiles = fs.readdirSync(uploads).filter(f => f.toLowerCase().endsWith('.pdf'))
|
pdfFiles = fs.readdirSync(internalUploads).filter(f => f.toLowerCase().endsWith('.pdf'))
|
||||||
.map(f => {
|
.map(f => {
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
const safeName = path.basename(String(f || ''))
|
||||||
const filePath = path.join(uploads, f)
|
const filePath = `${internalUploads}/${safeName}`
|
||||||
return { f, mtime: fs.statSync(filePath).mtimeMs }
|
return { f, mtime: fs.statSync(filePath).mtimeMs, dir: internalUploads }
|
||||||
})
|
})
|
||||||
.sort((a,b) => b.mtime - a.mtime)
|
|
||||||
.map(x => x.f)
|
|
||||||
}
|
}
|
||||||
const apiPdf = pdfFiles.find(n => !n.includes('sample')) || pdfFiles[0]
|
|
||||||
|
// Do NOT fall back to public/uploads to avoid encouraging public exposure.
|
||||||
|
if (pdfFiles.length === 0) {
|
||||||
|
if (fs.existsSync(publicUploads)) {
|
||||||
|
console.warn('WARN: PDFs exist in public/uploads. Please migrate them to server/data/uploads using scripts/migrate-public-galerie-to-metadata.js')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfFiles = pdfFiles.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
const apiPdfEntry = pdfFiles.find(e => !e.f.includes('sample')) || pdfFiles[0]
|
||||||
|
|
||||||
await inspect(template)
|
await inspect(template)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
if (apiPdfEntry) await inspect(path.join(apiPdfEntry.dir, apiPdfEntry.f))
|
||||||
if (apiPdf) await inspect(path.join(uploads, apiPdf))
|
else console.log('No API-generated PDF found in server/data/uploads or public/uploads')
|
||||||
else console.log('No API-generated PDF found in public/uploads')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(e => { console.error(e); process.exit(1) })
|
main().catch(e => { console.error(e); process.exit(1) })
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { randomUUID } from 'crypto'
|
|||||||
|
|
||||||
const allowed = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'])
|
const allowed = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'])
|
||||||
|
|
||||||
const getDataPath = (filename) => {
|
const getDataRoot = () => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
|
return cwd.endsWith('.output') ? `${cwd}/../server/data` : `${cwd}/server/data`
|
||||||
return path.join(cwd, 'server/data', filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GALERIE_DIR = getDataPath('galerie')
|
const DATA_ROOT = getDataRoot()
|
||||||
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
const GALERIE_DIR = `${DATA_ROOT}/galerie`
|
||||||
|
const GALERIE_METADATA = `${DATA_ROOT}/galerie-metadata.json`
|
||||||
const PUBLIC_GALERIE_DIR = path.join(process.cwd(), 'public', 'galerie')
|
const PUBLIC_GALERIE_DIR = path.join(process.cwd(), 'public', 'galerie')
|
||||||
|
|
||||||
function titleFromFilename(filename) {
|
function titleFromFilename(filename) {
|
||||||
|
|||||||
55
scripts/re-encrypt-membership-applications.js
Normal file
55
scripts/re-encrypt-membership-applications.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Re-Encrypt Klartext-Mitgliedsanträge mit aktuellem ENCRYPTION_KEY
|
||||||
|
// Backup wird als .bak angelegt
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { encryptObject } from '../server/utils/encryption.js'
|
||||||
|
|
||||||
|
const DIR = path.join(process.cwd(), 'server/data/membership-applications')
|
||||||
|
const KEY = process.env.ENCRYPTION_KEY
|
||||||
|
|
||||||
|
if (!KEY) {
|
||||||
|
console.error('ENCRYPTION_KEY fehlt! Bitte als Environment-Variable setzen.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reencryptFile(file) {
|
||||||
|
const safeFile = path.basename(String(file || ''))
|
||||||
|
const filePath = `${DIR}/${safeFile}`
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8')
|
||||||
|
// Prüfe, ob bereits verschlüsselt (v2: Prefix)
|
||||||
|
if (content.startsWith('v2:')) {
|
||||||
|
console.log('Überspringe (bereits verschlüsselt):', file)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Prüfe, ob Klartext-JSON
|
||||||
|
if (!content.trim().startsWith('{')) {
|
||||||
|
console.warn('Überspringe (kein Klartext-JSON):', file)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Backup anlegen
|
||||||
|
await fs.copyFile(filePath, filePath + '.bak')
|
||||||
|
// Verschlüsseln
|
||||||
|
const obj = JSON.parse(content)
|
||||||
|
const encrypted = encryptObject(obj, KEY)
|
||||||
|
await fs.writeFile(filePath, encrypted, 'utf8')
|
||||||
|
console.log('Re-Encrypted:', file)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler bei', file, ':', e.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const files = await fs.readdir(DIR)
|
||||||
|
let changed = 0
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.json')) continue
|
||||||
|
const ok = await reencryptFile(file)
|
||||||
|
if (ok) changed++
|
||||||
|
}
|
||||||
|
console.log('Fertig. Re-encrypted:', changed, 'Dateien.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
22
scripts/set-all-members-active.js
Normal file
22
scripts/set-all-members-active.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Setzt für alle Mitglieder in members.json das Feld active: true und verschlüsselt neu
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { readMembers, writeMembers } from '../server/utils/members.js'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const members = await readMembers()
|
||||||
|
if (!members || members.length === 0) {
|
||||||
|
console.log('Keine Mitglieder geladen (members.json leer oder nicht entschlüsselbar)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let changed = 0
|
||||||
|
for (const m of members) {
|
||||||
|
if (m.active !== true) {
|
||||||
|
m.active = true
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeMembers(members)
|
||||||
|
console.log(`Fertig. ${changed} Mitglieder auf active: true gesetzt und gespeichert.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
69
scripts/set-visibility.js
Normal file
69
scripts/set-visibility.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import arg from 'arg'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = arg({
|
||||||
|
'--email': String,
|
||||||
|
'--showEmail': Boolean,
|
||||||
|
'--showPhone': Boolean,
|
||||||
|
'--showAddress': Boolean,
|
||||||
|
'--target': String // 'members'|'users'|'both'
|
||||||
|
})
|
||||||
|
|
||||||
|
const email = args['--email']
|
||||||
|
if (!email) {
|
||||||
|
console.error('Usage: node scripts/set-visibility.js --email <email> [--showEmail] [--showPhone] [--showAddress] [--target both|members|users]')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEmail = '--showEmail' in args ? Boolean(args['--showEmail']) : undefined
|
||||||
|
const showPhone = '--showPhone' in args ? Boolean(args['--showPhone']) : undefined
|
||||||
|
const showAddress = '--showAddress' in args ? Boolean(args['--showAddress']) : undefined
|
||||||
|
const target = args['--target'] || 'both'
|
||||||
|
|
||||||
|
const membersUtils = await import('../server/utils/members.js')
|
||||||
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
|
|
||||||
|
if (target === 'both' || target === 'members') {
|
||||||
|
const members = await membersUtils.readMembers()
|
||||||
|
let changed = false
|
||||||
|
for (const m of members) {
|
||||||
|
if ((m.email || '').toLowerCase() === email.toLowerCase()) {
|
||||||
|
m.visibility = m.visibility || {}
|
||||||
|
if (showEmail !== undefined) m.visibility.showEmail = showEmail
|
||||||
|
if (showPhone !== undefined) m.visibility.showPhone = showPhone
|
||||||
|
if (showAddress !== undefined) m.visibility.showAddress = showAddress
|
||||||
|
changed = true
|
||||||
|
console.log('Updated manual member visibility for', email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await membersUtils.writeMembers(members)
|
||||||
|
console.log('Wrote members.json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'both' || target === 'users') {
|
||||||
|
const users = await authUtils.readUsers()
|
||||||
|
let changed = false
|
||||||
|
for (const u of users) {
|
||||||
|
if ((u.email || '').toLowerCase() === email.toLowerCase()) {
|
||||||
|
u.visibility = u.visibility || {}
|
||||||
|
if (showEmail !== undefined) u.visibility.showEmail = showEmail
|
||||||
|
if (showPhone !== undefined) u.visibility.showPhone = showPhone
|
||||||
|
if (showAddress !== undefined) u.visibility.showAddress = showAddress
|
||||||
|
changed = true
|
||||||
|
console.log('Updated user visibility for', email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await authUtils.writeUsers(users)
|
||||||
|
console.log('Wrote users.json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
80
scripts/split-names-in-members.js
Normal file
80
scripts/split-names-in-members.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'fs'
|
||||||
|
import { promises as fsp } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { readMembers, writeMembers } from '../server/utils/members.js'
|
||||||
|
|
||||||
|
// Script to split `name` into firstName/lastName for members.json.
|
||||||
|
// Usage:
|
||||||
|
// node scripts/split-names-in-members.js # dry-run, no writes
|
||||||
|
// node scripts/split-names-in-members.js --apply # apply changes and create backup
|
||||||
|
|
||||||
|
const MEMBERS_FILE_PATH = path.join(process.cwd(), 'server/data/members.json')
|
||||||
|
|
||||||
|
function extractNames(name) {
|
||||||
|
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
|
||||||
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply')
|
||||||
|
|
||||||
|
console.log('Reading members via server utils (handles encryption)...')
|
||||||
|
const members = await readMembers()
|
||||||
|
if (!Array.isArray(members)) {
|
||||||
|
console.error('Unerwartetes Format von members:', typeof members)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
for (const m of members) {
|
||||||
|
if ((!m.firstName || !m.lastName) && m.name) {
|
||||||
|
const { firstName, lastName } = extractNames(m.name)
|
||||||
|
if (!m.firstName) m.firstName = firstName
|
||||||
|
if (!m.lastName) m.lastName = lastName
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
console.log('Keine Änderungen erforderlich. Alle Mitglieder haben firstName/lastName.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Gefundene Änderungen: Mitglieder mit ergänztenn Namen werden ${apply ? 'angewendet' : 'nur angezeigt (dry-run)'}.`)
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
console.log('Vorschau der Änderungen (erstes 10 geänderte Mitglieder):')
|
||||||
|
let count = 0
|
||||||
|
for (const m of members) {
|
||||||
|
if (m.firstName || m.lastName) {
|
||||||
|
console.log('-', m.id || '(keine id)', m.firstName, m.lastName, '-', m.name)
|
||||||
|
count++
|
||||||
|
if (count >= 10) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('\nFühre das Skript mit --apply aus, um die Änderungen dauerhaft zu schreiben (Backup wird erstellt).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup of raw file (may be encrypted)
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const backupPath = MEMBERS_FILE_PATH + `.bak.${timestamp}`
|
||||||
|
try {
|
||||||
|
await fsp.copyFile(MEMBERS_FILE_PATH, backupPath)
|
||||||
|
console.log('Backup erstellt:', backupPath)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Konnte kein Backup anlegen (Datei evtl. nicht vorhanden):', err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write members using writeMembers (will handle encryption)
|
||||||
|
await writeMembers(members)
|
||||||
|
console.log('Mitglieder erfolgreich aktualisiert und verschlüsselt gespeichert.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fehler:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
61
scripts/split-names-in-membership-apps.js
Normal file
61
scripts/split-names-in-membership-apps.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Script to split name field in membership application JSON files under server/data/membership-applications/ (ESM)
|
||||||
|
// It will create backups for each modified file.
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const APPS_DIR = path.join(__dirname, '../server/data/membership-applications')
|
||||||
|
|
||||||
|
function extractNames(name) {
|
||||||
|
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
|
||||||
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(APPS_DIR)) {
|
||||||
|
console.error('membership-applications Verzeichnis nicht gefunden:', APPS_DIR)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(APPS_DIR).filter(f => f.endsWith('.json'))
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('Keine Bewerbungsdateien gefunden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = 0
|
||||||
|
for (const file of files) {
|
||||||
|
const p = path.join(APPS_DIR, file)
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = JSON.parse(fs.readFileSync(p, 'utf8'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Lesen von', p, err.message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!data.firstName || !data.lastName) && data.name) {
|
||||||
|
const { firstName, lastName } = extractNames(data.name)
|
||||||
|
data.firstName = data.firstName || firstName
|
||||||
|
data.lastName = data.lastName || lastName
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
const backup = p + '.bak'
|
||||||
|
fs.copyFileSync(p, backup)
|
||||||
|
fs.writeFileSync(p, JSON.stringify(data, null, 2))
|
||||||
|
modified++
|
||||||
|
console.log('Updated', p, '-> backup at', backup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done. Modified files:', modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
49
scripts/split-names-in-users.js
Normal file
49
scripts/split-names-in-users.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Script: split-names-in-users.js (ESM)
|
||||||
|
// Splittet das Feld "name" in firstName und lastName für alle User in users.json, falls noch nicht vorhanden.
|
||||||
|
// Backup wird automatisch angelegt.
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const usersPath = path.join(__dirname, '../server/data/users.json')
|
||||||
|
const backupPath = usersPath + '.bak.' + new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
|
||||||
|
function extractNames(name) {
|
||||||
|
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
|
||||||
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(usersPath)) {
|
||||||
|
console.error('users.json nicht gefunden:', usersPath)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const users = JSON.parse(fs.readFileSync(usersPath, 'utf8'))
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if ((!user.firstName || !user.lastName) && user.name) {
|
||||||
|
const { firstName, lastName } = extractNames(user.name)
|
||||||
|
if (!user.firstName) user.firstName = firstName
|
||||||
|
if (!user.lastName) user.lastName = lastName
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
fs.copyFileSync(usersPath, backupPath)
|
||||||
|
fs.writeFileSync(usersPath, JSON.stringify(users, null, 2))
|
||||||
|
console.log('Felder firstName/lastName ergänzt. Backup:', backupPath)
|
||||||
|
} else {
|
||||||
|
console.log('Keine Änderungen nötig. Alle Namen bereits gesplittet.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
40
server/api/app/version.get.js
Normal file
40
server/api/app/version.get.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
async function readPackageVersion() {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const candidatePaths = [
|
||||||
|
path.join(cwd, 'package.json'),
|
||||||
|
path.join(cwd, '../package.json')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const packageJsonPath of candidatePaths) {
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'))
|
||||||
|
if (packageJson?.version) {
|
||||||
|
return String(packageJson.version)
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Try next candidate path (e.g. .output runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getCookie(event, 'auth_token')
|
||||||
|
const user = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Nicht authentifiziert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: await readPackageVersion()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -133,7 +133,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const optionsDuration = Date.now() - optionsStart
|
const optionsDuration = Date.now() - optionsStart
|
||||||
console.log(`[DEBUG] Registration options generated (${optionsDuration}ms)`, {
|
console.log('[DEBUG] Registration options generated', { optionsDurationMs: optionsDuration,
|
||||||
hasChallenge: !!options.challenge,
|
hasChallenge: !!options.challenge,
|
||||||
challengeLength: options.challenge?.length,
|
challengeLength: options.challenge?.length,
|
||||||
rpId: options.rp?.id,
|
rpId: options.rp?.id,
|
||||||
@@ -185,7 +185,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const totalDuration = Date.now() - requestStart
|
const totalDuration = Date.now() - requestStart
|
||||||
|
|
||||||
// Debug: Prüfe die vollständige Options-Struktur
|
// Debug: Prüfe die vollständige Options-Struktur
|
||||||
console.log(`[DEBUG] Returning options (total: ${totalDuration}ms)`, {
|
console.log('[DEBUG] Returning options', { totalDurationMs: totalDuration,
|
||||||
registrationId,
|
registrationId,
|
||||||
optionsKeys: Object.keys(options),
|
optionsKeys: Object.keys(options),
|
||||||
challengeLength: options.challenge?.length,
|
challengeLength: options.challenge?.length,
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
} catch (verifyError) {
|
} catch (verifyError) {
|
||||||
const verifyDuration = Date.now() - verifyStart
|
const verifyDuration = Date.now() - verifyStart
|
||||||
console.error(`[DEBUG] Verification error (${verifyDuration}ms):`, {
|
console.error('[DEBUG] Verification error:', { verifyDurationMs: verifyDuration,
|
||||||
error: verifyError,
|
error: verifyError,
|
||||||
message: verifyError?.message,
|
message: verifyError?.message,
|
||||||
cause: verifyError?.cause?.message,
|
cause: verifyError?.cause?.message,
|
||||||
@@ -175,7 +175,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const verifyDuration = Date.now() - verifyStart
|
const verifyDuration = Date.now() - verifyStart
|
||||||
const { verified, registrationInfo } = verification
|
const { verified, registrationInfo } = verification
|
||||||
|
|
||||||
console.log(`[DEBUG] Verification completed (${verifyDuration}ms)`, {
|
console.log('[DEBUG] Verification completed', { verifyDurationMs: verifyDuration,
|
||||||
verified,
|
verified,
|
||||||
hasRegistrationInfo: !!registrationInfo,
|
hasRegistrationInfo: !!registrationInfo,
|
||||||
credentialId: registrationInfo?.credentialID ? 'present' : 'missing',
|
credentialId: registrationInfo?.credentialID ? 'present' : 'missing',
|
||||||
@@ -246,7 +246,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
|
|
||||||
const totalDuration = Date.now() - requestStart
|
const totalDuration = Date.now() - requestStart
|
||||||
console.log(`[DEBUG] User created successfully (total: ${totalDuration}ms)`, {
|
console.log('[DEBUG] User created successfully', { totalDurationMs: totalDuration,
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
email: newUser.email.substring(0, 10) + '...',
|
email: newUser.email.substring(0, 10) + '...',
|
||||||
hasPasskey: newUser.passkeys?.length > 0,
|
hasPasskey: newUser.passkeys?.length > 0,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
||||||
import nodemailer from 'nodemailer'
|
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
||||||
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { name, email, phone, password } = body
|
const { name, email, phone, password, geburtsdatum, visibility } = body
|
||||||
|
|
||||||
if (!name || !email || !password) {
|
if (!name || !email || !password || !geburtsdatum) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Name, E-Mail und Passwort sind erforderlich'
|
message: 'Name, E-Mail, Geburtsdatum und Passwort sind erforderlich'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
name,
|
name,
|
||||||
phone: phone || '',
|
phone: phone || '',
|
||||||
|
geburtsdatum,
|
||||||
|
visibility: {
|
||||||
|
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true
|
||||||
|
},
|
||||||
role: 'mitglied',
|
role: 'mitglied',
|
||||||
active: false, // Requires admin approval
|
active: false, // Requires admin approval
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
@@ -55,61 +59,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
users.push(newUser)
|
users.push(newUser)
|
||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
|
|
||||||
// Send notification email to admin
|
// Send notification to Vorstand/admin via central email service
|
||||||
try {
|
try {
|
||||||
const smtpUser = process.env.SMTP_USER
|
await sendRegistrationNotification({ name, email, phone })
|
||||||
const smtpPass = process.env.SMTP_PASS
|
|
||||||
|
|
||||||
if (!smtpUser || !smtpPass) {
|
|
||||||
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
|
|
||||||
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
|
||||||
// Continue without sending email
|
|
||||||
} else {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Email to admin
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
|
||||||
to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de',
|
|
||||||
subject: 'Neue Registrierung - Harheimer TC',
|
|
||||||
html: `
|
|
||||||
<h2>Neue Registrierung</h2>
|
|
||||||
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Name:</strong> ${name}</li>
|
|
||||||
<li><strong>E-Mail:</strong> ${email}</li>
|
|
||||||
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
|
|
||||||
</ul>
|
|
||||||
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Email to user
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
|
||||||
to: email,
|
|
||||||
subject: 'Registrierung erhalten - Harheimer TC',
|
|
||||||
html: `
|
|
||||||
<h2>Registrierung erhalten</h2>
|
|
||||||
<p>Hallo ${name},</p>
|
|
||||||
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
|
|
||||||
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
|
|
||||||
<br>
|
|
||||||
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
|
||||||
// Continue anyway - user is registered
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
98
server/api/birthdays.get.js
Normal file
98
server/api/birthdays.get.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { readMembers, normalizeDate } from '../utils/members.js'
|
||||||
|
import { readUsers, migrateUserRoles, getUserFromToken, verifyToken } from '../utils/auth.js'
|
||||||
|
|
||||||
|
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
|
||||||
|
function getUpcomingBirthdays(entries, daysAhead = 28) {
|
||||||
|
const now = new Date()
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
// iterate entries with geburtsdatum and name
|
||||||
|
for (const e of entries) {
|
||||||
|
const raw = e.geburtsdatum
|
||||||
|
if (!raw) continue
|
||||||
|
const parsed = new Date(raw)
|
||||||
|
if (isNaN(parsed.getTime())) continue
|
||||||
|
|
||||||
|
// Build next occurrence for this year
|
||||||
|
const thisYear = now.getFullYear()
|
||||||
|
const occ = new Date(thisYear, parsed.getMonth(), parsed.getDate())
|
||||||
|
|
||||||
|
// If already passed this year, consider next year
|
||||||
|
if (occ < now) {
|
||||||
|
occ.setFullYear(thisYear + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.ceil((occ - now) / (1000 * 60 * 60 * 24))
|
||||||
|
if (diffDays >= 0 && diffDays <= daysAhead) {
|
||||||
|
results.push({
|
||||||
|
name: e.name || `${e.firstName || ''} ${e.lastName || ''}`.trim(),
|
||||||
|
dayMonth: `${String(occ.getDate()).padStart(2, '0')}.${String(occ.getMonth()+1).padStart(2, '0')}`,
|
||||||
|
date: occ,
|
||||||
|
diffDays
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by upcoming date
|
||||||
|
results.sort((a, b) => a.date - b.date)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Determine viewer for visibility rules; token optional
|
||||||
|
const token = getCookie(event, 'auth_token')
|
||||||
|
let currentUser = null
|
||||||
|
if (token) {
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (decoded) {
|
||||||
|
currentUser = await getUserFromToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualMembers = await readMembers()
|
||||||
|
const registeredUsers = await readUsers()
|
||||||
|
|
||||||
|
// Build unified list of candidates with geburtsdatum and visibility
|
||||||
|
const candidates = []
|
||||||
|
|
||||||
|
for (const m of manualMembers) {
|
||||||
|
const normalizedStatus = m.status ? String(m.status).toLowerCase() : ''
|
||||||
|
const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== ''
|
||||||
|
const isAccepted = hasExplicitAcceptanceFlag
|
||||||
|
? (
|
||||||
|
m.active === true ||
|
||||||
|
m.accepted === true ||
|
||||||
|
normalizedStatus === 'accepted'
|
||||||
|
)
|
||||||
|
: true
|
||||||
|
if (!isAccepted) continue
|
||||||
|
const vis = m.visibility || {}
|
||||||
|
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
|
||||||
|
candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const u of registeredUsers) {
|
||||||
|
if (!u.active) continue
|
||||||
|
const vis = u.visibility || {}
|
||||||
|
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
|
||||||
|
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect visibility: if viewer is vorstand they see all birthdays
|
||||||
|
const isPrivilegedViewer = currentUser ? (Array.isArray(currentUser.roles) ? currentUser.roles.includes('vorstand') : currentUser.role === 'vorstand') : false
|
||||||
|
|
||||||
|
const filtered = candidates.filter(c => c.geburtsdatum && (isPrivilegedViewer || c.visibility.showBirthday === true))
|
||||||
|
|
||||||
|
const upcoming = getUpcomingBirthdays(filtered, 28)
|
||||||
|
|
||||||
|
// Return only next 4 weeks entries with name and dayMonth
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
birthdays: upcoming.map(b => ({ name: b.name, dayMonth: b.dayMonth, inDays: b.diffDays }))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abrufen der Geburtstage:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
17
server/api/cms/contact-requests.get.js
Normal file
17
server/api/cms/contact-requests.get.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
import { readContactRequests } from '../../utils/contact-requests.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getCookie(event, 'auth_token')
|
||||||
|
const currentUser = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
|
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Zugriff verweigert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = await readContactRequests()
|
||||||
|
return requests
|
||||||
|
})
|
||||||
75
server/api/cms/contact-requests/[id]/reply.post.js
Normal file
75
server/api/cms/contact-requests/[id]/reply.post.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
|
||||||
|
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
|
||||||
|
|
||||||
|
function createTransporter() {
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
||||||
|
if (!smtpUser || !smtpPass) return null
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: { user: smtpUser, pass: smtpPass }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getCookie(event, 'auth_token')
|
||||||
|
const currentUser = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
|
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Zugriff verweigert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const replyMessage = String(body?.message || '').trim()
|
||||||
|
if (!replyMessage) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Antworttext fehlt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = getRouterParam(event, 'id')
|
||||||
|
if (!requestId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Anfrage-ID fehlt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await readContactRequests()
|
||||||
|
const target = all.find((r) => r.id === requestId)
|
||||||
|
if (!target) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = createTransporter()
|
||||||
|
if (!transporter) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'SMTP ist nicht konfiguriert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSubject = target.subject || 'Kontaktanfrage'
|
||||||
|
const responseSubject = `Aw: ${originalSubject}`
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Harheimer TC" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
|
||||||
|
to: target.email,
|
||||||
|
subject: responseSubject,
|
||||||
|
text: replyMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
const responderEmail = currentUser.email || ''
|
||||||
|
const updated = await addContactReply({
|
||||||
|
requestId,
|
||||||
|
replyText: replyMessage,
|
||||||
|
responderEmail
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
request: updated
|
||||||
|
}
|
||||||
|
})
|
||||||
33
server/api/cms/contact-requests/[id]/toggle-status.patch.js
Normal file
33
server/api/cms/contact-requests/[id]/toggle-status.patch.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
|
||||||
|
import { readContactRequests, updateContactRequestStatus } from '../../../../utils/contact-requests.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getCookie(event, 'auth_token')
|
||||||
|
const currentUser = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
|
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Zugriff verweigert'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = getRouterParam(event, 'id')
|
||||||
|
if (!requestId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Anfrage-ID fehlt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await readContactRequests()
|
||||||
|
const target = all.find((r) => r.id === requestId)
|
||||||
|
if (!target) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = target.status === 'beantwortet' ? 'offen' : 'beantwortet'
|
||||||
|
const updated = await updateContactRequestStatus(requestId, newStatus)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
request: updated
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -26,9 +26,12 @@ const getDataPath = (filename) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Multer-Konfiguration für PDF-Uploads
|
// Multer-Konfiguration für PDF-Uploads
|
||||||
|
// Store uploads in internal data directory instead of public/
|
||||||
|
const DOCUMENTS_DIR = getDataPath('documents')
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
cb(null, 'public/documents/')
|
cb(null, DOCUMENTS_DIR)
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
cb(null, 'satzung.pdf')
|
cb(null, 'satzung.pdf')
|
||||||
@@ -75,7 +78,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.join(process.cwd(), 'public', 'documents'), { recursive: true })
|
// Ensure internal documents dir exists
|
||||||
|
await fs.mkdir(DOCUMENTS_DIR, { recursive: true })
|
||||||
|
|
||||||
// Multer-Middleware für File-Upload
|
// Multer-Middleware für File-Upload
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -133,8 +137,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
configData.seiten = {}
|
configData.seiten = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve the uploaded statute via internal media proxy
|
||||||
configData.seiten.satzung = {
|
configData.seiten.satzung = {
|
||||||
pdfUrl: '/documents/satzung.pdf',
|
pdfUrl: '/api/media/documents/satzung.pdf',
|
||||||
content: htmlContent
|
content: htmlContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
let token = getCookie(event, 'auth_token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const authHeader = getHeader(event, 'authorization')
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
token = authHeader.substring(7).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentUser = token ? await getUserFromToken(token) : null
|
const currentUser = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
@@ -45,25 +53,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wichtig: In Production werden statische Dateien aus `.output/public` ausgeliefert.
|
// Neuer Ablauf (Option B): Schreibe CSVs ausschließlich in internes Datenverzeichnis,
|
||||||
// Wenn PM2 `cwd` auf das Repo-Root setzt, ist `process.cwd()` NICHT `.output` –
|
// damit keine direkten Schreibzugriffe auf `public/` stattfinden.
|
||||||
// daher schreiben wir robust in alle sinnvollen Zielorte:
|
// Später kann ein kontrollierter Deploy-/Sync-Prozess die Daten aus `server/data/public-data`
|
||||||
// - `.output/public/data/<file>` (damit die laufende Instanz sofort die neuen Daten liefert)
|
// in die öffentlich ausgelieferte `public/`-Location übernehmen.
|
||||||
// - `public/data/<file>` (damit der nächste Build die Daten wieder übernimmt)
|
|
||||||
//
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
// filename is validated against allowlist above, path traversal prevented
|
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
|
|
||||||
const pathExists = async (p) => {
|
|
||||||
try {
|
|
||||||
await fs.access(p)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeFileAtomicAndVerify = async (targetPath, data) => {
|
const writeFileAtomicAndVerify = async (targetPath, data) => {
|
||||||
const dataDir = path.dirname(targetPath)
|
const dataDir = path.dirname(targetPath)
|
||||||
await fs.mkdir(dataDir, { recursive: true })
|
await fs.mkdir(dataDir, { recursive: true })
|
||||||
@@ -88,56 +83,37 @@ export default defineEventHandler(async (event) => {
|
|||||||
// ggf. inkonsistente Inhalte ausgeliefert (Browser meldet Partial Transfer).
|
// ggf. inkonsistente Inhalte ausgeliefert (Browser meldet Partial Transfer).
|
||||||
// Daher: nach erfolgreichem Schreiben alte Varianten entfernen.
|
// Daher: nach erfolgreichem Schreiben alte Varianten entfernen.
|
||||||
for (const ext of ['.gz', '.br']) {
|
for (const ext of ['.gz', '.br']) {
|
||||||
try { await fs.unlink(`${targetPath}${ext}`) } catch (_e3) {}
|
try { await fs.unlink(`${targetPath}${ext}`) } catch (_e3) { /* no-op */ }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// best-effort cleanup
|
// best-effort cleanup
|
||||||
try { await fs.unlink(tmpPath) } catch (_e2) {}
|
try { await fs.unlink(tmpPath) } catch (_e2) { /* no-op */ }
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preferred: das tatsächlich ausgelieferte Verzeichnis in Production
|
// Ziel: internes Datenverzeichnis unter `server/data/public-data` (persistente, interne Quelle)
|
||||||
// (Nuxt/Nitro serve static aus `.output/public`)
|
const dataTargetsByFile = {
|
||||||
const preferredPaths = []
|
'vereinsmeisterschaften.csv': [`${cwd}/server/data/public-data/vereinsmeisterschaften.csv`, `${cwd}/../server/data/public-data/vereinsmeisterschaften.csv`],
|
||||||
if (await pathExists(path.join(cwd, '.output/public'))) {
|
'mannschaften.csv': [`${cwd}/server/data/public-data/mannschaften.csv`, `${cwd}/../server/data/public-data/mannschaften.csv`],
|
||||||
preferredPaths.push(path.join(cwd, '.output/public/data', filename))
|
'termine.csv': [`${cwd}/server/data/public-data/termine.csv`, `${cwd}/../server/data/public-data/termine.csv`],
|
||||||
}
|
'spielplan.csv': [`${cwd}/server/data/public-data/spielplan.csv`, `${cwd}/../server/data/public-data/spielplan.csv`]
|
||||||
if (await pathExists(path.join(cwd, '../.output/public'))) {
|
|
||||||
preferredPaths.push(path.join(cwd, '../.output/public/data', filename))
|
|
||||||
}
|
}
|
||||||
|
const internalPaths = dataTargetsByFile[filename] || []
|
||||||
|
|
||||||
// Fallbacks: Source-Public (für Persistenz bei nächstem Build) und diverse cwd-Layouts
|
const uniquePaths = [...new Set([...internalPaths])]
|
||||||
const fallbackPaths = [
|
|
||||||
path.join(cwd, 'public/data', filename),
|
|
||||||
path.join(cwd, '../public/data', filename)
|
|
||||||
]
|
|
||||||
|
|
||||||
const uniquePaths = [...new Set([...preferredPaths, ...fallbackPaths])]
|
|
||||||
const writeResults = []
|
const writeResults = []
|
||||||
const writeErrors = []
|
const writeErrors = []
|
||||||
let wrotePreferred = false
|
|
||||||
|
|
||||||
for (const targetPath of uniquePaths) {
|
for (const targetPath of uniquePaths) {
|
||||||
try {
|
try {
|
||||||
await writeFileAtomicAndVerify(targetPath, content)
|
await writeFileAtomicAndVerify(targetPath, content)
|
||||||
writeResults.push(targetPath)
|
writeResults.push(targetPath)
|
||||||
if (preferredPaths.includes(targetPath)) wrotePreferred = true
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
writeErrors.push({ targetPath, error: e?.message || String(e) })
|
writeErrors.push({ targetPath, error: e?.message || String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn wir ein `.output/public` gefunden haben, MUSS auch dorthin geschrieben worden sein.
|
|
||||||
// Sonst melden wir nicht "Erfolg", weil die laufende Instanz dann weiterhin alte/defekte Daten ausliefert.
|
|
||||||
if (preferredPaths.length > 0 && !wrotePreferred) {
|
|
||||||
console.error('CSV wurde NICHT in .output/public geschrieben. Errors:', writeErrors)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'CSV konnte nicht in das ausgelieferte Verzeichnis geschrieben werden'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (writeResults.length === 0) {
|
if (writeResults.length === 0) {
|
||||||
console.error('Konnte CSV-Datei in keinen Zielpfad schreiben:', writeErrors)
|
console.error('Konnte CSV-Datei in keinen Zielpfad schreiben:', writeErrors)
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
@@ -17,25 +17,32 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const isVorstand = hasRole(currentUser, 'vorstand')
|
const isVorstand = hasRole(currentUser, 'vorstand')
|
||||||
|
|
||||||
// Return users without Passwörter; Kontaktdaten nur für Vorstand
|
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
|
||||||
|
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
|
||||||
|
|
||||||
const safeUsers = users.map(u => {
|
const safeUsers = users.map(u => {
|
||||||
const migrated = migrateUserRoles({ ...u })
|
const migrated = migrateUserRoles({ ...u })
|
||||||
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
|
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
|
||||||
|
|
||||||
const email = isVorstand ? u.email : undefined
|
return canSeePrivate
|
||||||
const phone = isVorstand ? (u.phone || '') : undefined
|
? {
|
||||||
|
|
||||||
return {
|
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email,
|
email: u.email,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
roles: roles,
|
roles: roles,
|
||||||
role: roles[0] || 'mitglied', // Rückwärtskompatibilität
|
role: roles[0] || 'mitglied',
|
||||||
phone,
|
phone: u.phone || '',
|
||||||
active: u.active,
|
active: u.active,
|
||||||
created: u.created,
|
created: u.created,
|
||||||
lastLogin: u.lastLogin
|
lastLogin: u.lastLogin
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
role: roles[0] || 'mitglied',
|
||||||
|
active: u.active,
|
||||||
|
lastLogin: u.lastLogin
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { userId, roles } = body
|
const { userId, roles } = body
|
||||||
|
|
||||||
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter']
|
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter', 'trainer']
|
||||||
const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied'])
|
const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied'])
|
||||||
|
|
||||||
if (!rolesArray.every(r => validRoles.includes(r))) {
|
if (!rolesArray.every(r => validRoles.includes(r))) {
|
||||||
|
|||||||
@@ -1,10 +1,99 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { createContactRequest } from '../utils/contact-requests.js'
|
||||||
|
import { readUsers, migrateUserRoles } from '../utils/auth.js'
|
||||||
|
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
// filename is always a hardcoded constant ('config.json'), never user input
|
||||||
|
const getConfigPath = () => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) return `${cwd}/../server/data/config.json`
|
||||||
|
return `${cwd}/server/data/config.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configFile = getConfigPath()
|
||||||
|
const raw = await fs.readFile(configFile, 'utf-8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Konfiguration für Kontaktanfragen:', error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectRecipients(config) {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
||||||
|
|
||||||
|
if (!isProduction) {
|
||||||
|
return ['tsschulz@tsschulz.de']
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = []
|
||||||
|
|
||||||
|
// Vorstand
|
||||||
|
if (config?.vorstand && typeof config.vorstand === 'object') {
|
||||||
|
for (const member of Object.values(config.vorstand)) {
|
||||||
|
if (member?.email && typeof member.email === 'string' && member.email.trim()) {
|
||||||
|
recipients.push(member.email.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trainer
|
||||||
|
if (Array.isArray(config?.trainer)) {
|
||||||
|
for (const trainer of config.trainer) {
|
||||||
|
if (trainer?.email && typeof trainer.email === 'string' && trainer.email.trim()) {
|
||||||
|
recipients.push(trainer.email.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusätzlich: Benutzer mit Trainer-Rolle aus dem Login-System
|
||||||
|
try {
|
||||||
|
const users = await readUsers()
|
||||||
|
for (const rawUser of users) {
|
||||||
|
const user = migrateUserRoles({ ...rawUser })
|
||||||
|
const roles = Array.isArray(user.roles) ? user.roles : []
|
||||||
|
if (roles.includes('trainer') && user.email && String(user.email).trim()) {
|
||||||
|
recipients.push(String(user.email).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Trainer-Empfänger aus Benutzerdaten:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = [...new Set(recipients)]
|
||||||
|
if (unique.length > 0) return unique
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
if (config?.website?.verantwortlicher?.email) {
|
||||||
|
return [config.website.verantwortlicher.email]
|
||||||
|
}
|
||||||
|
if (process.env.SMTP_USER) {
|
||||||
|
return [process.env.SMTP_USER]
|
||||||
|
}
|
||||||
|
return ['j.dichmann@gmx.de']
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransporter() {
|
||||||
|
const smtpUser = process.env.SMTP_USER
|
||||||
|
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
||||||
|
if (!smtpUser || !smtpPass) return null
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: { user: smtpUser, pass: smtpPass }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
// Validierung der Eingabedaten
|
|
||||||
if (!body.name || !body.email || !body.subject || !body.message) {
|
if (!body.name || !body.email || !body.subject || !body.message) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
@@ -12,7 +101,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Validierung
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
if (!emailRegex.test(body.email)) {
|
if (!emailRegex.test(body.email)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -21,34 +109,32 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTP-Konfiguration (hier können Sie Ihre SMTP-Daten eintragen)
|
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
|
||||||
const smtpUser = process.env.SMTP_USER || 'j.dichmann@gmx.de'
|
await createContactRequest({
|
||||||
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
name: String(body.name).trim(),
|
||||||
|
email: String(body.email).trim(),
|
||||||
if (!smtpUser || !smtpPass) {
|
phone: body.phone ? String(body.phone).trim() : '',
|
||||||
throw createError({
|
subject: String(body.subject).trim(),
|
||||||
statusCode: 500,
|
message: String(body.message).trim()
|
||||||
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, // true für 465, false für andere Ports
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// E-Mail-Template
|
const config = await loadConfig()
|
||||||
|
const recipients = await collectRecipients(config)
|
||||||
|
const transporter = createTransporter()
|
||||||
|
|
||||||
|
if (!transporter) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Anfrage wurde gespeichert. E-Mail-Versand ist aktuell nicht konfiguriert.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowLabel = new Date().toLocaleString('de-DE')
|
||||||
const emailHtml = `
|
const emailHtml = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
<h2 style="color: #dc2626; border-bottom: 2px solid #dc2626; padding-bottom: 10px;">
|
<h2 style="color: #dc2626; border-bottom: 2px solid #dc2626; padding-bottom: 10px;">
|
||||||
Neue Kontaktanfrage - Harheimer TC
|
Neue Kontaktanfrage - Harheimer TC
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style="background-color: #f9fafb; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
<div style="background-color: #f9fafb; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
<h3 style="color: #374151; margin-top: 0;">Kontaktdaten:</h3>
|
<h3 style="color: #374151; margin-top: 0;">Kontaktdaten:</h3>
|
||||||
<p><strong>Name:</strong> ${body.name}</p>
|
<p><strong>Name:</strong> ${body.name}</p>
|
||||||
@@ -56,21 +142,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
<p><strong>Telefon:</strong> ${body.phone || 'Nicht angegeben'}</p>
|
<p><strong>Telefon:</strong> ${body.phone || 'Nicht angegeben'}</p>
|
||||||
<p><strong>Betreff:</strong> ${body.subject}</p>
|
<p><strong>Betreff:</strong> ${body.subject}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background-color: #ffffff; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
<div style="background-color: #ffffff; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
<h3 style="color: #374151; margin-top: 0;">Nachricht:</h3>
|
<h3 style="color: #374151; margin-top: 0;">Nachricht:</h3>
|
||||||
<p style="white-space: pre-wrap; line-height: 1.6;">${body.message}</p>
|
<p style="white-space: pre-wrap; line-height: 1.6;">${body.message}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||||
<p>Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.</p>
|
<p>Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.</p>
|
||||||
<p>Zeitstempel: ${new Date().toLocaleString('de-DE')}</p>
|
<p>Zeitstempel: ${nowLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
const emailText = `
|
const emailText = `Neue Kontaktanfrage - Harheimer TC
|
||||||
Neue Kontaktanfrage - Harheimer TC
|
|
||||||
|
|
||||||
Kontaktdaten:
|
Kontaktdaten:
|
||||||
Name: ${body.name}
|
Name: ${body.name}
|
||||||
@@ -83,36 +166,29 @@ ${body.message}
|
|||||||
|
|
||||||
---
|
---
|
||||||
Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.
|
Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.
|
||||||
Zeitstempel: ${new Date().toLocaleString('de-DE')}
|
Zeitstempel: ${nowLabel}`
|
||||||
`
|
|
||||||
|
|
||||||
// E-Mail senden
|
await transporter.sendMail({
|
||||||
const mailOptions = {
|
from: `"Harheimer TC Website" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
|
||||||
from: `"Harheimer TC Website" <${process.env.SMTP_USER || 'j.dichmann@gmx.de'}>`,
|
to: recipients.join(', '),
|
||||||
to: 'j.dichmann@gmx.de',
|
|
||||||
replyTo: body.email,
|
replyTo: body.email,
|
||||||
subject: `Kontaktanfrage: ${body.subject}`,
|
subject: `Kontaktanfrage: ${body.subject}`,
|
||||||
text: emailText,
|
text: emailText,
|
||||||
html: emailHtml
|
html: emailHtml
|
||||||
}
|
})
|
||||||
|
|
||||||
await transporter.sendMail(mailOptions)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'E-Mail wurde erfolgreich gesendet!'
|
message: 'Anfrage wurde erfolgreich gesendet.'
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden der E-Mail:', error)
|
console.error('Fehler bei Kontaktanfrage:', error)
|
||||||
|
|
||||||
if (error.statusCode) {
|
if (error.statusCode) throw error
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Fehler beim Senden der E-Mail. Bitte versuchen Sie es später erneut.'
|
statusMessage: 'Fehler beim Senden der Anfrage. Bitte versuchen Sie es später erneut.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,35 +45,49 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await readGalerieMetadata()
|
let metadata = []
|
||||||
|
try {
|
||||||
|
metadata = await readGalerieMetadata()
|
||||||
|
if (!Array.isArray(metadata)) {
|
||||||
|
console.warn('Galerie-Metadaten haben unerwartetes Format, verwende leere Liste')
|
||||||
|
metadata = []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Lesen der Galerie-Metadaten, liefere leeres Ergebnis:', e.message)
|
||||||
|
metadata = []
|
||||||
|
}
|
||||||
|
|
||||||
// Filtere Bilder basierend auf Sichtbarkeit
|
// Filtere Bilder basierend auf Sichtbarkeit
|
||||||
const visibleImages = metadata.filter(image => {
|
const visibleImages = metadata.filter(image => {
|
||||||
// Öffentliche Bilder sind für alle sichtbar
|
// Defensive checks
|
||||||
|
if (!image || typeof image !== 'object') return false
|
||||||
if (image.isPublic) return true
|
if (image.isPublic) return true
|
||||||
// Private Bilder nur für eingeloggte Mitglieder
|
|
||||||
return isLoggedIn
|
return isLoggedIn
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sortiere nach Upload-Datum (neueste zuerst)
|
// Sortiere nach Upload-Datum (neueste zuerst) - defensive
|
||||||
visibleImages.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt))
|
visibleImages.sort((a, b) => {
|
||||||
|
const ta = new Date(a.uploadedAt || 0).getTime()
|
||||||
|
const tb = new Date(b.uploadedAt || 0).getTime()
|
||||||
|
return tb - ta
|
||||||
|
})
|
||||||
|
|
||||||
// Pagination
|
// Pagination (defensive defaults)
|
||||||
const page = parseInt(getQuery(event).page) || 1
|
const page = Math.max(1, parseInt(getQuery(event).page) || 1)
|
||||||
const perPage = 10
|
const perPage = Math.max(1, parseInt(getQuery(event).perPage) || 10)
|
||||||
const start = (page - 1) * perPage
|
const start = (page - 1) * perPage
|
||||||
const end = start + perPage
|
const paginatedImages = visibleImages.slice(start, start + perPage)
|
||||||
const paginatedImages = visibleImages.slice(start, end)
|
|
||||||
|
|
||||||
|
// Konsistente Rückgabeform
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
images: paginatedImages.map(img => ({
|
images: paginatedImages.map(img => ({
|
||||||
id: img.id,
|
id: img.id || img.filename || null,
|
||||||
title: img.title,
|
title: img.title || '',
|
||||||
description: img.description,
|
description: img.description || '',
|
||||||
isPublic: img.isPublic,
|
isPublic: !!img.isPublic,
|
||||||
uploadedAt: img.uploadedAt,
|
uploadedAt: img.uploadedAt || null,
|
||||||
previewFilename: img.previewFilename
|
previewFilename: img.previewFilename || null
|
||||||
})),
|
})),
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const filename = 'mannschaften.csv'
|
const filename = 'mannschaften.csv'
|
||||||
|
|
||||||
|
// Prefer CMS write target first (server/data/public-data),
|
||||||
|
// then legacy locations.
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
path.join(cwd, 'server/data/public-data', filename),
|
||||||
|
path.join(cwd, '../server/data/public-data', filename),
|
||||||
|
path.join(cwd, '.output/server/data', filename),
|
||||||
|
path.join(cwd, 'server/data', filename),
|
||||||
path.join(cwd, '.output/public/data', filename),
|
path.join(cwd, '.output/public/data', filename),
|
||||||
path.join(cwd, 'public/data', filename),
|
path.join(cwd, 'public/data', filename),
|
||||||
path.join(cwd, '../.output/public/data', filename),
|
path.join(cwd, '../.output/public/data', filename),
|
||||||
@@ -24,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
let csvPath = null
|
let csvPath = null
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
if (await exists(p)) {
|
if (await exists(p)) {
|
||||||
csvPath = p
|
csvPath = p
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import fs from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken, verifyToken } from '../../../utils/auth.js'
|
import { getUserFromToken, verifyToken } from '../../../utils/auth.js'
|
||||||
|
|
||||||
const getDataPath = (filename) => {
|
const getDataRoot = () => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
|
return cwd.endsWith('.output') ? `${cwd}/../server/data` : `${cwd}/server/data`
|
||||||
return path.join(cwd, 'server/data', filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GALERIE_DIR = getDataPath('galerie')
|
const DATA_ROOT = getDataRoot()
|
||||||
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
const GALERIE_DIR = `${DATA_ROOT}/galerie`
|
||||||
|
const GALERIE_METADATA = `${DATA_ROOT}/galerie-metadata.json`
|
||||||
|
|
||||||
async function readGalerieMetadata() {
|
async function readGalerieMetadata() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const filePath = resolveInternalPath(reqPath)
|
const filePath = resolveInternalPath(reqPath)
|
||||||
// check existence and ensure it stays within baseDir
|
// check existence and ensure it stays within baseDir
|
||||||
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
|
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
|
||||||
const resolved = path.resolve(filePath)
|
const resolved = path.normalize(filePath)
|
||||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
const normalizedBaseDir = path.normalize(baseDir + path.sep)
|
||||||
|
if (!resolved.startsWith(normalizedBaseDir)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Pfad' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Pfad' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,38 @@ export default defineEventHandler(async (event) => {
|
|||||||
const emailToIndexMap = new Map() // email -> index in mergedMembers
|
const emailToIndexMap = new Map() // email -> index in mergedMembers
|
||||||
const nameToIndexMap = new Map() // name -> index in mergedMembers
|
const nameToIndexMap = new Map() // name -> index in mergedMembers
|
||||||
|
|
||||||
// First, add all manual members and build lookup maps
|
// First, add manual members. Legacy records without explicit status flags
|
||||||
|
// are treated as accepted members; only explicit non-accepted records are skipped.
|
||||||
for (let i = 0; i < manualMembers.length; i++) {
|
for (let i = 0; i < manualMembers.length; i++) {
|
||||||
const member = manualMembers[i]
|
const member = manualMembers[i]
|
||||||
|
const normalizedStatus = member.status ? String(member.status).toLowerCase() : ''
|
||||||
|
const hasExplicitAcceptanceFlag = member.active !== undefined || member.accepted !== undefined || normalizedStatus !== ''
|
||||||
|
const isAccepted = hasExplicitAcceptanceFlag
|
||||||
|
? (
|
||||||
|
member.active === true ||
|
||||||
|
member.accepted === true ||
|
||||||
|
normalizedStatus === 'accepted'
|
||||||
|
)
|
||||||
|
: true
|
||||||
|
if (!isAccepted) {
|
||||||
|
// Skip applications that are not yet accepted
|
||||||
|
continue
|
||||||
|
}
|
||||||
const normalizedEmail = member.email?.toLowerCase().trim() || ''
|
const normalizedEmail = member.email?.toLowerCase().trim() || ''
|
||||||
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
|
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
|
||||||
const normalizedName = fullName.toLowerCase()
|
const normalizedName = fullName.toLowerCase()
|
||||||
|
|
||||||
const memberIndex = mergedMembers.length
|
const memberIndex = mergedMembers.length
|
||||||
|
// Ensure visibility flags are booleans for manual entries
|
||||||
|
const vis = member.visibility || {}
|
||||||
|
member.visibility = {
|
||||||
|
// Default: visible to all logged-in members unless explicitly hidden
|
||||||
|
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
|
||||||
|
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
|
||||||
|
// Address remains private by default
|
||||||
|
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
|
||||||
|
}
|
||||||
|
|
||||||
mergedMembers.push({
|
mergedMembers.push({
|
||||||
...member,
|
...member,
|
||||||
name: fullName, // Computed for display
|
name: fullName, // Computed for display
|
||||||
@@ -73,6 +97,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
const normalizedEmail = user.email?.toLowerCase().trim() || ''
|
const normalizedEmail = user.email?.toLowerCase().trim() || ''
|
||||||
const normalizedName = user.name?.toLowerCase().trim() || ''
|
const normalizedName = user.name?.toLowerCase().trim() || ''
|
||||||
|
|
||||||
|
// Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name
|
||||||
|
function extractNames(name) {
|
||||||
|
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
|
||||||
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this user matches an existing manual member using O(1) lookup
|
// Check if this user matches an existing manual member using O(1) lookup
|
||||||
let matchedManualIndex = -1
|
let matchedManualIndex = -1
|
||||||
|
|
||||||
@@ -108,6 +140,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Merge with existing manual member
|
// Merge with existing manual member
|
||||||
const migratedUser = migrateUserRoles({ ...user })
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
// Extrahiere Namen nur, wenn Felder leer sind
|
||||||
|
const { firstName, lastName } = extractNames(user.name)
|
||||||
mergedMembers[matchedManualIndex] = {
|
mergedMembers[matchedManualIndex] = {
|
||||||
...mergedMembers[matchedManualIndex],
|
...mergedMembers[matchedManualIndex],
|
||||||
hasLogin: true,
|
hasLogin: true,
|
||||||
@@ -115,21 +149,46 @@ export default defineEventHandler(async (event) => {
|
|||||||
loginRoles: roles,
|
loginRoles: roles,
|
||||||
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
|
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
|
||||||
lastLogin: user.lastLogin,
|
lastLogin: user.lastLogin,
|
||||||
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true
|
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true,
|
||||||
|
geburtsdatum: mergedMembers[matchedManualIndex].geburtsdatum || user.geburtsdatum || '',
|
||||||
|
firstName: mergedMembers[matchedManualIndex].firstName || firstName,
|
||||||
|
lastName: mergedMembers[matchedManualIndex].lastName || lastName,
|
||||||
|
editable: true
|
||||||
|
}
|
||||||
|
// If the registered user has visibility preferences, apply them (coerce to booleans)
|
||||||
|
if (user.visibility && typeof user.visibility === 'object') {
|
||||||
|
const vis = mergedMembers[matchedManualIndex].visibility || {}
|
||||||
|
mergedMembers[matchedManualIndex].visibility = {
|
||||||
|
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
|
||||||
|
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone),
|
||||||
|
showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add as new member (from login system)
|
// Add as new member (from login system)
|
||||||
const migratedUser = migrateUserRoles({ ...user })
|
const migratedUser = migrateUserRoles({ ...user })
|
||||||
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
||||||
|
// Registered-only user: default to privacy-preserving visibility (hidden) unless user explicitly set visibility elsewhere
|
||||||
|
// Use stored visibility from user if present, otherwise default to false
|
||||||
|
const userVis = user.visibility || {}
|
||||||
|
const { firstName, lastName } = extractNames(user.name)
|
||||||
mergedMembers.push({
|
mergedMembers.push({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
geburtsdatum: user.geburtsdatum || '',
|
||||||
email: user.email,
|
email: user.email,
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
address: '',
|
address: '',
|
||||||
|
visibility: {
|
||||||
|
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
|
||||||
|
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone),
|
||||||
|
showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress)
|
||||||
|
},
|
||||||
notes: `Rolle(n): ${roles.join(', ')}`,
|
notes: `Rolle(n): ${roles.join(', ')}`,
|
||||||
source: 'login',
|
source: 'login',
|
||||||
editable: false,
|
editable: true,
|
||||||
hasLogin: true,
|
hasLogin: true,
|
||||||
loginEmail: user.email,
|
loginEmail: user.email,
|
||||||
loginRoles: roles,
|
loginRoles: roles,
|
||||||
@@ -142,21 +201,76 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Sort by name
|
// Sort by name
|
||||||
mergedMembers.sort((a, b) => a.name.localeCompare(b.name))
|
mergedMembers.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
// Die Mitgliederliste ist nur für authentifizierte Nutzer sichtbar (siehe oben).
|
||||||
|
// Respektiere individuelle Sichtbarkeitspräferenzen (user.visibility)
|
||||||
|
const currentUserToken = token
|
||||||
|
const isViewerAuthenticated = !!currentUser
|
||||||
|
// Only 'vorstand' may override member visibility
|
||||||
|
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
|
||||||
|
|
||||||
// Serverseitiger Datenschutz: Kontaktdaten nur für Vorstand
|
// Filtere den Admin-Account heraus
|
||||||
const isVorstand = hasRole(currentUser, 'vorstand')
|
const filteredMembers = mergedMembers.filter(m => m.email?.toLowerCase() !== 'admin@harheimertc.de')
|
||||||
const safeMembers = isVorstand
|
const sanitizedMembers = filteredMembers.map(member => {
|
||||||
? mergedMembers
|
// Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them
|
||||||
: mergedMembers.map(m => ({
|
const visibility = member.visibility || {}
|
||||||
...m,
|
|
||||||
email: undefined,
|
const showEmail = visibility.showEmail === undefined ? true : Boolean(visibility.showEmail)
|
||||||
phone: undefined,
|
const showPhone = visibility.showPhone === undefined ? true : Boolean(visibility.showPhone)
|
||||||
address: undefined
|
const showAddress = visibility.showAddress === undefined ? false : Boolean(visibility.showAddress)
|
||||||
}))
|
|
||||||
|
// Determine if contact info existed but was hidden to the viewer
|
||||||
|
const hadEmail = !!member.email
|
||||||
|
const hadPhone = !!member.phone
|
||||||
|
const hadAddress = !!member.address
|
||||||
|
const hadBirthday = !!member.geburtsdatum
|
||||||
|
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
|
||||||
|
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
|
||||||
|
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
|
||||||
|
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true)))
|
||||||
|
const contactHidden = (!emailVisible && hadEmail) || (!phoneVisible && hadPhone) || (!addressVisible && hadAddress)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
firstName: member.firstName || '',
|
||||||
|
lastName: member.lastName || '',
|
||||||
|
source: member.source,
|
||||||
|
editable: member.editable,
|
||||||
|
hasLogin: member.hasLogin,
|
||||||
|
loginRoles: member.loginRoles,
|
||||||
|
loginRole: member.loginRole,
|
||||||
|
lastLogin: member.lastLogin,
|
||||||
|
isMannschaftsspieler: member.isMannschaftsspieler,
|
||||||
|
hasHallKey: member.hasHallKey === true || member.hasHallenschluessel === true,
|
||||||
|
notes: member.notes || '',
|
||||||
|
// Sichtbarkeits-Flags explizit mitgeben
|
||||||
|
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
|
||||||
|
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
|
||||||
|
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress),
|
||||||
|
showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday),
|
||||||
|
// Privileged viewers (vorstand) always see contact fields
|
||||||
|
email: emailVisible ? member.email : undefined,
|
||||||
|
phone: phoneVisible ? member.phone : undefined,
|
||||||
|
address: addressVisible ? member.address : undefined,
|
||||||
|
// Birthday: expose only day + month and only if allowed; do not expose year or age
|
||||||
|
birthday: (birthdayVisible && hadBirthday) ? (function(){
|
||||||
|
try {
|
||||||
|
const d = new Date(member.geburtsdatum)
|
||||||
|
if (isNaN(d.getTime())) return undefined
|
||||||
|
const day = `${d.getDate()}`.padStart(2, '0')
|
||||||
|
const month = `${d.getMonth()+1}`.padStart(2, '0')
|
||||||
|
return `${day}.${month}`
|
||||||
|
} catch (_e) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})() : undefined,
|
||||||
|
geburtsdatum: member.geburtsdatum || undefined // Originalfeld für das Edit-Formular
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
members: safeMembers
|
members: sanitizedMembers
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Abrufen der Mitgliederliste:', error)
|
console.error('Fehler beim Abrufen der Mitgliederliste:', error)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveMember } from '../utils/members.js'
|
import { saveMember } from '../utils/members.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -21,21 +21,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = verifyToken(token)
|
const user = await getUserFromToken(token)
|
||||||
|
|
||||||
if (!decoded) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Ungültiges Token.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUserById(decoded.id)
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
message: 'Benutzer nicht gefunden.'
|
message: 'Nicht authentifiziert oder Benutzer nicht gefunden.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +39,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler } = body
|
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active } = body
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -57,10 +48,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!geburtsdatum) {
|
if (!geburtsdatum && !id) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Geburtsdatum ist erforderlich, um Duplikate zu vermeiden.'
|
message: 'Geburtsdatum ist für neue Mitglieder erforderlich, um Duplikate zu vermeiden.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +65,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
phone: phone || '',
|
phone: phone || '',
|
||||||
address: address || '',
|
address: address || '',
|
||||||
notes: notes || '',
|
notes: notes || '',
|
||||||
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true'
|
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
|
||||||
|
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
|
||||||
|
active: typeof active === 'boolean' ? active : true
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { decryptObject } from '../../utils/encryption.js'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
// Nur Vorstand oder Admin darf Mitgliedschaftsantraege lesen
|
||||||
|
const token = getCookie(event, 'auth_token')
|
||||||
|
const currentUser = token ? await getUserFromToken(token) : null
|
||||||
|
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand')) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Zugriff verweigert' })
|
||||||
|
}
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production'
|
const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken } from '../../../utils/auth.js'
|
import { getUserFromToken } from '../../../utils/auth.js'
|
||||||
|
import { getServerDataPath } from '../../../utils/paths.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload-Verzeichnis finden (intern)
|
// Upload-Verzeichnis finden (intern)
|
||||||
const uploadDir = path.join(process.cwd(), '..', 'server', 'data', 'uploads')
|
const uploadDir = getServerDataPath('uploads')
|
||||||
console.log('Upload-Verzeichnis:', uploadDir)
|
console.log('Upload-Verzeichnis:', uploadDir)
|
||||||
|
|
||||||
// Alle Dateien im Upload-Verzeichnis durchsuchen
|
// Alle Dateien im Upload-Verzeichnis durchsuchen
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { exec } from 'child_process'
|
|||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { StandardFonts } from 'pdf-lib'
|
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
|
||||||
import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js'
|
import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js'
|
||||||
|
import { sendMembershipEmail as sendMembershipEmailUtil } from '../../utils/email-service.js'
|
||||||
|
import { getProjectPath, getServerDataPath } from '../../utils/paths.js'
|
||||||
|
|
||||||
// const require = createRequire(import.meta.url) // Nicht verwendet
|
// const require = createRequire(import.meta.url) // Nicht verwendet
|
||||||
const execAsync = promisify(exec)
|
const execAsync = promisify(exec)
|
||||||
@@ -98,7 +100,7 @@ function generateLaTeXContent(data) {
|
|||||||
// LaTeX-Inhalt mit korrekten Escapes generieren
|
// LaTeX-Inhalt mit korrekten Escapes generieren
|
||||||
let latexContent = `\\documentclass[12pt,a4paper]{article}
|
let latexContent = `\\documentclass[12pt,a4paper]{article}
|
||||||
\\usepackage[utf8]{inputenc}
|
\\usepackage[utf8]{inputenc}
|
||||||
\\usepackage[ngerman]{babel}
|
\\usepackage[german]{babel}
|
||||||
\\usepackage{geometry}
|
\\usepackage{geometry}
|
||||||
\\usepackage{enumitem}
|
\\usepackage{enumitem}
|
||||||
\\usepackage{xcolor}
|
\\usepackage{xcolor}
|
||||||
@@ -305,71 +307,11 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
|
|||||||
return `${filename}.txt`
|
return `${filename}.txt`
|
||||||
}
|
}
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
// filename is always a hardcoded constant (e.g., 'membership-applications'), never user input
|
|
||||||
function getDataPath(filename) {
|
function getDataPath(filename) {
|
||||||
// Immer den absoluten Pfad zum Projekt-Root verwenden
|
return getServerDataPath(filename)
|
||||||
// In der Entwicklung: process.cwd() ist bereits das Projekt-Root
|
|
||||||
// In der Produktion: process.cwd() ist .output, daher ein Verzeichnis zurück
|
|
||||||
const isDev = process.env.NODE_ENV === 'development'
|
|
||||||
const projectRoot = isDev ? process.cwd() : path.resolve(process.cwd(), '..')
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
return path.join(projectRoot, 'server', 'data', filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMembershipEmail(data, _filename, _event) {
|
// Use central email service
|
||||||
try {
|
|
||||||
const configPath = getDataPath('config.json')
|
|
||||||
const configData = await fs.readFile(configPath, 'utf8')
|
|
||||||
const config = JSON.parse(configData)
|
|
||||||
|
|
||||||
let recipients = []
|
|
||||||
let subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
|
|
||||||
|
|
||||||
// Sammle alle verfügbaren E-Mail-Adressen
|
|
||||||
const availableEmails = []
|
|
||||||
|
|
||||||
// Vorsitzender E-Mail hinzufügen (falls vorhanden)
|
|
||||||
if (config.vorstand.vorsitzender.email && config.vorstand.vorsitzender.email.trim() !== '') {
|
|
||||||
availableEmails.push(config.vorstand.vorsitzender.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schriftführer E-Mail hinzufügen (falls vorhanden)
|
|
||||||
if (config.vorstand.schriftfuehrer.email && config.vorstand.schriftfuehrer.email.trim() !== '') {
|
|
||||||
availableEmails.push(config.vorstand.schriftfuehrer.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Wenn keine E-Mails verfügbar sind, verwende tsschulz@tsschulz.de
|
|
||||||
if (availableEmails.length === 0) {
|
|
||||||
recipients = ['tsschulz@tsschulz.de']
|
|
||||||
} else {
|
|
||||||
recipients = availableEmails
|
|
||||||
}
|
|
||||||
|
|
||||||
// In nicht-Produktionsumgebung: Alle E-Mails an tsschulz@tsschulz.de
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
recipients = ['tsschulz@tsschulz.de']
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = `Ein neuer Mitgliedschaftsantrag wurde eingereicht.
|
|
||||||
|
|
||||||
Antragsteller: ${data.vorname} ${data.nachname}
|
|
||||||
Mitgliedschaftsart: ${data.mitgliedschaftsart}
|
|
||||||
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
|
|
||||||
|
|
||||||
Das ausgefüllte Formular ist als Anhang verfügbar.`
|
|
||||||
|
|
||||||
// E-Mail-Versand implementieren (hier würde normalerweise nodemailer verwendet)
|
|
||||||
console.log('E-Mail würde gesendet werden an:', recipients)
|
|
||||||
console.log('Betreff:', subject)
|
|
||||||
console.log('Nachricht:', message)
|
|
||||||
|
|
||||||
return { success: true, recipients, subject, message }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Senden der E-Mail:', error)
|
|
||||||
return { success: false, error: error.message }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -402,17 +344,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const filename = `beitrittserklärung_${timestamp}`
|
const filename = `beitrittserklärung_${timestamp}`
|
||||||
|
|
||||||
// Temp-Verzeichnis erstellen
|
// Temp-Verzeichnis erstellen (bewusst außerhalb von .output,
|
||||||
const tempDir = path.join(process.cwd(), '.output', 'temp', 'latex')
|
// da Deploy-Artefakte dort je nach Setup schreibgeschützt sein können)
|
||||||
|
const tempDir = getServerDataPath('tmp', 'latex')
|
||||||
await fs.mkdir(tempDir, { recursive: true })
|
await fs.mkdir(tempDir, { recursive: true })
|
||||||
|
const uploadsDir = getDataPath('uploads')
|
||||||
|
await fs.mkdir(uploadsDir, { recursive: true })
|
||||||
|
let finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen
|
// PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen
|
||||||
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
|
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
|
||||||
async function fillPdfTemplate(data) {
|
async function fillPdfTemplate(data) {
|
||||||
// Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template
|
// Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template
|
||||||
const fillablePath = path.join(process.cwd(), 'server', 'templates', 'mitgliedschaft-fillable.pdf')
|
const fillablePath = getProjectPath('server', 'templates', 'mitgliedschaft-fillable.pdf')
|
||||||
const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf')
|
const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || getProjectPath('server', 'templates', 'Aufnahmeantrag 2025.pdf')
|
||||||
let arrayBuffer
|
let arrayBuffer
|
||||||
try {
|
try {
|
||||||
const localExists = await fs.stat(localPath).then(() => true).catch(() => false)
|
const localExists = await fs.stat(localPath).then(() => true).catch(() => false)
|
||||||
@@ -425,8 +371,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`)
|
if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`)
|
||||||
arrayBuffer = await res.arrayBuffer()
|
arrayBuffer = await res.arrayBuffer()
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (templateLoadError) {
|
||||||
throw new Error('Template-Laden fehlgeschlagen: ' + e.message)
|
throw new Error('Template-Laden fehlgeschlagen: ' + templateLoadError.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(arrayBuffer)
|
const pdfDoc = await PDFDocument.load(arrayBuffer)
|
||||||
@@ -442,7 +388,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden.
|
// Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden.
|
||||||
const pages = pdfDoc.getPages()
|
const pages = pdfDoc.getPages()
|
||||||
const firstPage = pages[0]
|
const firstPage = pages[0]
|
||||||
firstPage.getSize()
|
const { height } = firstPage.getSize()
|
||||||
|
|
||||||
// Schätzwerte: (x, y) in Punkten von linker unteren Ecke
|
// Schätzwerte: (x, y) in Punkten von linker unteren Ecke
|
||||||
// Diese Werte müssen nach Sichtprüfung justiert werden.
|
// Diese Werte müssen nach Sichtprüfung justiert werden.
|
||||||
@@ -480,22 +426,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
bank: { x: leftX, y: baseY - gap * 3 + yOffset }
|
bank: { x: leftX, y: baseY - gap * 3 + yOffset }
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawText = (page, text, x, y, size = 11) => {
|
|
||||||
page.drawText(text || '', {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
size,
|
|
||||||
font: pdfDoc.embedStandardFont ? undefined : undefined,
|
|
||||||
// default black
|
|
||||||
color: undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Einbettung der Standard-Schrift (Helvetica)
|
// Einbettung der Standard-Schrift (Helvetica)
|
||||||
const helveticaFont = await pdfDoc.embedFont(PDFDocument.PDFName ? 'Helvetica' : 'Helvetica')
|
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
||||||
// NOTE: pdf-lib's embedFont usage above uses embedFont(fontBytes) in normal case;
|
|
||||||
// to keep it simple we attempt to embed built-in font via embedFont(StandardFonts)
|
|
||||||
// Fallback: drawText will work with default font if embed fails.
|
|
||||||
|
|
||||||
// Zeichne die Felder
|
// Zeichne die Felder
|
||||||
try {
|
try {
|
||||||
@@ -522,8 +454,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
} else if (data.mitgliedschaftsart === 'passiv') {
|
} else if (data.mitgliedschaftsart === 'passiv') {
|
||||||
firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont })
|
firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont })
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (checkboxError) {
|
||||||
console.warn('Fehler beim Zeichnen der Checkbox:', e.message)
|
console.warn('Fehler beim Zeichnen der Checkbox:', checkboxError.message)
|
||||||
}
|
}
|
||||||
// Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true
|
// Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true
|
||||||
if (data && data.debug) {
|
if (data && data.debug) {
|
||||||
@@ -542,12 +474,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
// small label a bit to the right
|
// small label a bit to the right
|
||||||
firstPage.drawText(key, { x: c.x + 8, y: c.y - 1, size: 7, color: rgb(0.6, 0, 0), font: helveticaFont })
|
firstPage.drawText(key, { x: c.x + 8, y: c.y - 1, size: 7, color: rgb(0.6, 0, 0), font: helveticaFont })
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (debugOverlayError) {
|
||||||
console.warn('Debug overlay fehlgeschlagen:', e.message)
|
console.warn('Debug overlay fehlgeschlagen:', debugOverlayError.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (positionalDrawingError) {
|
||||||
console.warn('Fehler beim positional drawing:', e.message)
|
console.warn('Fehler beim positional drawing:', positionalDrawingError.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await pdfDoc.save()
|
const pdfBytes = await pdfDoc.save()
|
||||||
@@ -642,8 +574,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (fieldFillError) {
|
||||||
console.warn('Fehler beim Befüllen Feld', fname, e.message)
|
console.warn('Fehler beim Befüllen Feld', fname, fieldFillError.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +583,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
try {
|
try {
|
||||||
const helv2 = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
const helv2 = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
||||||
form.updateFieldAppearances(helv2)
|
form.updateFieldAppearances(helv2)
|
||||||
} catch (_e) {
|
} catch (appearanceError) {
|
||||||
console.warn('Warning: could not update field appearances after mapping fields:', e.message)
|
console.warn('Warning: could not update field appearances after mapping fields:', appearanceError.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await pdfDoc.save()
|
const pdfBytes = await pdfDoc.save()
|
||||||
@@ -660,14 +592,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let usedTemplate = false
|
let usedTemplate = false
|
||||||
const uploadsDir = getDataPath('uploads')
|
|
||||||
await fs.mkdir(uploadsDir, { recursive: true })
|
|
||||||
try {
|
try {
|
||||||
const filled = await fillPdfTemplate(data)
|
const filled = await fillPdfTemplate(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||||
await fs.writeFile(finalPdfPath, filled)
|
await fs.writeFile(finalPdfPath, filled)
|
||||||
// Do NOT copy filled PDFs into public repo uploads to avoid accidental exposure.
|
// Do NOT copy filled PDFs into public repo uploads to avoid accidental exposure.
|
||||||
usedTemplate = true
|
usedTemplate = true
|
||||||
@@ -678,11 +608,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
let emailResult
|
let emailResult
|
||||||
if (usedTemplate) {
|
if (usedTemplate) {
|
||||||
// E-Mail senden
|
// E-Mail senden via zentralen Service (pass full path)
|
||||||
emailResult = await sendMembershipEmail(data, filename, event)
|
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
||||||
// Antragsdaten verschlüsselt speichern
|
// Antragsdaten verschlüsselt speichern
|
||||||
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
||||||
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
|
const encryptedData = JSON.stringify(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -720,7 +650,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
||||||
// filename is generated from timestamp, tempDir is controlled, command injection prevented
|
// filename is generated from timestamp, tempDir is controlled, command injection prevented
|
||||||
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
|
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
|
||||||
|
try {
|
||||||
await execAsync(command)
|
await execAsync(command)
|
||||||
|
} catch (pdflatexError) {
|
||||||
|
const maybePdfPath = path.join(tempDir, `${filename}.pdf`)
|
||||||
|
const pdfExists = await fs.stat(maybePdfPath).then(() => true).catch(() => false)
|
||||||
|
if (!pdfExists) throw pdflatexError
|
||||||
|
console.warn('pdflatex meldete Fehlercode, aber PDF wurde erzeugt. Fahre fort.')
|
||||||
|
}
|
||||||
|
|
||||||
// PDF-Datei in Uploads-Verzeichnis kopieren
|
// PDF-Datei in Uploads-Verzeichnis kopieren
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -728,15 +665,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
const pdfPath = path.join(tempDir, `${filename}.pdf`)
|
const pdfPath = path.join(tempDir, `${filename}.pdf`)
|
||||||
await fs.mkdir(uploadsDir, { recursive: true })
|
await fs.mkdir(uploadsDir, { recursive: true })
|
||||||
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||||
await fs.copyFile(pdfPath, finalPdfPath)
|
await fs.copyFile(pdfPath, finalPdfPath)
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden via zentralen Service (pass full path)
|
||||||
emailResult = await sendMembershipEmail(data, filename, event)
|
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
||||||
|
|
||||||
// Antragsdaten verschlüsselt speichern
|
// Antragsdaten verschlüsselt speichern
|
||||||
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
||||||
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
|
const encryptedData = JSON.stringify(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -764,7 +701,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const fallbackFilename = await generateSimplePDF(data, filename, event)
|
const fallbackFilename = await generateSimplePDF(data, filename, event)
|
||||||
|
|
||||||
// E-Mail senden (Fallback)
|
// E-Mail senden (Fallback)
|
||||||
const emailResult = await sendMembershipEmail(data, filename, event)
|
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`))
|
||||||
|
|
||||||
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
||||||
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
||||||
@@ -773,7 +710,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
console.log('Upload-Verzeichnis:', getDataPath('uploads'))
|
console.log('Upload-Verzeichnis:', getDataPath('uploads'))
|
||||||
|
|
||||||
// Verfügbare Dateien auflisten
|
// Verfügbare Dateien auflisten
|
||||||
const uploadsDir = getDataPath('uploads')
|
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(uploadsDir)
|
const files = await fs.readdir(uploadsDir)
|
||||||
console.log('Verfügbare Dateien:', files)
|
console.log('Verfügbare Dateien:', files)
|
||||||
|
|||||||
@@ -1,51 +1,37 @@
|
|||||||
import { verifyToken, getUserById, migrateUserRoles } from '../utils/auth.js'
|
import { verifyToken, getUserFromToken } from '../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw createError({
|
throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||||
statusCode: 401,
|
|
||||||
message: 'Nicht authentifiziert.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = verifyToken(token)
|
const decoded = verifyToken(token)
|
||||||
|
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
throw createError({
|
throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
|
||||||
statusCode: 401,
|
|
||||||
message: 'Ungültiges Token.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUserById(decoded.id)
|
const user = await getUserFromToken(token)
|
||||||
|
if (!user) {
|
||||||
if (!user || user.active === false) {
|
throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
|
||||||
throw createError({
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Benutzer nicht gefunden oder inaktiv.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const migratedUser = migrateUserRoles({ ...user })
|
// Rückgabe des eigenen Profils inkl. Sichtbarkeitspräferenzen
|
||||||
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
|
||||||
|
|
||||||
// Return user data (without password)
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
roles: roles,
|
geburtsdatum: user.geburtsdatum || '',
|
||||||
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
|
visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Profil-Abruf-Fehler:', error)
|
console.error('Fehler beim Laden des Profil:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { name, email, phone, currentPassword, newPassword } = body
|
const { name, email, phone, geburtsdatum, currentPassword, newPassword } = body
|
||||||
|
|
||||||
if (!name || !email) {
|
if (!name || !email) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -58,6 +58,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
user.name = name
|
user.name = name
|
||||||
user.email = email
|
user.email = email
|
||||||
user.phone = phone || ''
|
user.phone = phone || ''
|
||||||
|
user.geburtsdatum = geburtsdatum || ''
|
||||||
|
|
||||||
|
// Optional visibility preferences (what to show to other logged-in members)
|
||||||
|
// Expected shape: { showEmail: boolean, showPhone: boolean, showAddress: boolean, showBirthday: boolean }
|
||||||
|
const visibility = body.visibility || body.visibilityPreferences || null
|
||||||
|
if (visibility && typeof visibility === 'object') {
|
||||||
|
user.visibility = user.visibility || {}
|
||||||
|
// Coerce values to booleans to be robust against string values from clients
|
||||||
|
if (visibility.showEmail !== undefined) user.visibility.showEmail = Boolean(visibility.showEmail)
|
||||||
|
if (visibility.showPhone !== undefined) user.visibility.showPhone = Boolean(visibility.showPhone)
|
||||||
|
if (visibility.showAddress !== undefined) user.visibility.showAddress = Boolean(visibility.showAddress)
|
||||||
|
if (visibility.showBirthday !== undefined) user.visibility.showBirthday = Boolean(visibility.showBirthday)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle password change
|
// Handle password change
|
||||||
if (currentPassword && newPassword) {
|
if (currentPassword && newPassword) {
|
||||||
@@ -93,6 +106,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
|
geburtsdatum: user.geburtsdatum || '',
|
||||||
|
visibility: user.visibility || {},
|
||||||
roles: roles,
|
roles: roles,
|
||||||
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
|
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lade Spielplandaten
|
// Lade Spielplandaten - bevorzugt aus server/data
|
||||||
const csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
|
let csvPath = path.join(process.cwd(), 'server/data/spielplan.csv')
|
||||||
let csvContent
|
try {
|
||||||
|
await fs.access(csvPath)
|
||||||
|
} catch {
|
||||||
|
csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
|
||||||
|
}
|
||||||
|
|
||||||
|
let csvContent
|
||||||
try {
|
try {
|
||||||
csvContent = await fs.readFile(csvPath, 'utf-8')
|
csvContent = await fs.readFile(csvPath, 'utf-8')
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|||||||
@@ -5,13 +5,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
try {
|
try {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
|
|
||||||
// In production (.output/server), working dir is .output
|
// Prefer internal server/data, fallback to public/data
|
||||||
let csvPath
|
let csvPath
|
||||||
if (cwd.endsWith('.output')) {
|
if (cwd.endsWith('.output')) {
|
||||||
|
csvPath = path.join(cwd, '../server/data/termine.csv')
|
||||||
|
// fallback
|
||||||
|
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
|
||||||
csvPath = path.join(cwd, '../public/data/termine.csv')
|
csvPath = path.join(cwd, '../public/data/termine.csv')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
csvPath = path.join(cwd, 'server/data/termine.csv')
|
||||||
|
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
|
||||||
csvPath = path.join(cwd, 'public/data/termine.csv')
|
csvPath = path.join(cwd, 'public/data/termine.csv')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const csv = await fs.readFile(csvPath, 'utf-8')
|
const csv = await fs.readFile(csvPath, 'utf-8')
|
||||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
try {
|
try {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
|
|
||||||
// In production (.output/server), working dir is .output
|
// Prefer internal server/data, fallback to public/data
|
||||||
let csvPath
|
let csvPath
|
||||||
if (cwd.endsWith('.output')) {
|
if (cwd.endsWith('.output')) {
|
||||||
|
csvPath = path.join(cwd, '../server/data/vereinsmeisterschaften.csv')
|
||||||
|
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
|
||||||
csvPath = path.join(cwd, '../public/data/vereinsmeisterschaften.csv')
|
csvPath = path.join(cwd, '../public/data/vereinsmeisterschaften.csv')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
csvPath = path.join(cwd, 'server/data/vereinsmeisterschaften.csv')
|
||||||
|
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
|
||||||
csvPath = path.join(cwd, 'public/data/vereinsmeisterschaften.csv')
|
csvPath = path.join(cwd, 'public/data/vereinsmeisterschaften.csv')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CSV-Datei direkt als Text zurückgeben (keine Caching-Probleme)
|
// CSV-Datei direkt als Text zurückgeben (keine Caching-Probleme)
|
||||||
const csv = await fs.readFile(csvPath, 'utf-8')
|
const csv = await fs.readFile(csvPath, 'utf-8')
|
||||||
|
|||||||
44
server/set-all-birthday-visible.cjs
Normal file
44
server/set-all-birthday-visible.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Script: set-all-birthday-visible.cjs
|
||||||
|
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const membersPath = path.join(__dirname, 'data', 'members.json')
|
||||||
|
|
||||||
|
let raw
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(membersPath, 'utf8')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Lesen von members.json:', e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let members
|
||||||
|
try {
|
||||||
|
members = JSON.parse(raw)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Parsen von members.json:', e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(members)) {
|
||||||
|
console.error('members.json ist kein Array!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = 0
|
||||||
|
for (const m of members) {
|
||||||
|
if (!m.visibility) m.visibility = {}
|
||||||
|
if (m.visibility.showBirthday !== true) {
|
||||||
|
m.visibility.showBirthday = true
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed > 0) {
|
||||||
|
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
|
||||||
|
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
|
||||||
|
} else {
|
||||||
|
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
|
||||||
|
}
|
||||||
44
server/set-all-birthday-visible.js
Normal file
44
server/set-all-birthday-visible.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Script: set-all-birthday-visible.js
|
||||||
|
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const membersPath = path.join(__dirname, 'data', 'members.json')
|
||||||
|
|
||||||
|
let raw
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(membersPath, 'utf8')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Lesen von members.json:', e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let members
|
||||||
|
try {
|
||||||
|
members = JSON.parse(raw)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Parsen von members.json:', e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(members)) {
|
||||||
|
console.error('members.json ist kein Array!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = 0
|
||||||
|
for (const m of members) {
|
||||||
|
if (!m.visibility) m.visibility = {}
|
||||||
|
if (m.visibility.showBirthday !== true) {
|
||||||
|
m.visibility.showBirthday = true
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed > 0) {
|
||||||
|
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
|
||||||
|
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
|
||||||
|
} else {
|
||||||
|
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
|
||||||
|
}
|
||||||
33
server/set-all-birthday-visible.mjs
Normal file
33
server/set-all-birthday-visible.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Script: set-all-birthday-visible.mjs
|
||||||
|
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung)
|
||||||
|
|
||||||
|
import { readMembers, writeMembers } from './utils/members.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let members = await readMembers();
|
||||||
|
if (!Array.isArray(members)) {
|
||||||
|
console.error('members.json ist kein Array!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
let changed = 0;
|
||||||
|
for (const m of members) {
|
||||||
|
if (!m.visibility) m.visibility = {};
|
||||||
|
if (m.visibility.showBirthday !== true) {
|
||||||
|
m.visibility.showBirthday = true;
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed > 0) {
|
||||||
|
await writeMembers(members);
|
||||||
|
console.log(`Flag für ${changed} Mitglieder gesetzt.`);
|
||||||
|
} else {
|
||||||
|
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
72
server/set-all-visibility-flags.mjs
Normal file
72
server/set-all-visibility-flags.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Script: set-all-visibility-flags.mjs
|
||||||
|
// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung)
|
||||||
|
|
||||||
|
import { readMembers, writeMembers } from './utils/members.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||||
|
|
||||||
|
const usersPath = path.resolve(process.cwd(), 'server/data/users.json');
|
||||||
|
|
||||||
|
async function updateVisibility(obj) {
|
||||||
|
let changed = 0;
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const m of obj) {
|
||||||
|
if (!m.visibility) m.visibility = {};
|
||||||
|
if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; }
|
||||||
|
if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; }
|
||||||
|
if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; }
|
||||||
|
if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUsersFile() {
|
||||||
|
let changed = 0;
|
||||||
|
try {
|
||||||
|
let raw = await fs.readFile(usersPath, 'utf8');
|
||||||
|
let users;
|
||||||
|
if (raw.trim().startsWith('v2:')) {
|
||||||
|
// encrypted, try to use decryptObject from encryption.js
|
||||||
|
const { decryptObject } = await import('./utils/encryption.js');
|
||||||
|
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
|
||||||
|
users = decryptObject(raw, key);
|
||||||
|
} else {
|
||||||
|
users = JSON.parse(raw);
|
||||||
|
}
|
||||||
|
changed = await updateVisibility(users);
|
||||||
|
// write back (encrypted if vorher encrypted)
|
||||||
|
if (raw.trim().startsWith('v2:')) {
|
||||||
|
const { encryptObject } = await import('./utils/encryption.js');
|
||||||
|
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
|
||||||
|
const encrypted = encryptObject(users, key);
|
||||||
|
await fs.writeFile(usersPath, encrypted, 'utf8');
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Bearbeiten von users.json:', e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let changedMembers = 0;
|
||||||
|
let changedUsers = 0;
|
||||||
|
// members.json (manuelle Mitglieder)
|
||||||
|
let members = await readMembers();
|
||||||
|
changedMembers = await updateVisibility(members);
|
||||||
|
if (changedMembers > 0) {
|
||||||
|
await writeMembers(members);
|
||||||
|
}
|
||||||
|
// users.json (Login-System)
|
||||||
|
changedUsers = await updateUsersFile();
|
||||||
|
console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
98
server/utils/contact-requests.js
Normal file
98
server/utils/contact-requests.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
// filename is always a hardcoded constant, never user input
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACT_REQUESTS_FILE = getDataPath('contact-requests.json')
|
||||||
|
|
||||||
|
export async function readContactRequests() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(CONTACT_REQUESTS_FILE, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
console.error('Fehler beim Lesen der Kontaktanfragen:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeContactRequests(items) {
|
||||||
|
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContactRequest(data) {
|
||||||
|
const current = await readContactRequests()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const item = {
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: 'offen',
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone || '',
|
||||||
|
subject: data.subject,
|
||||||
|
message: data.message,
|
||||||
|
replies: []
|
||||||
|
}
|
||||||
|
current.unshift(item)
|
||||||
|
await writeContactRequests(current)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addContactReply({ requestId, replyText, responderEmail }) {
|
||||||
|
const current = await readContactRequests()
|
||||||
|
const index = current.findIndex((r) => r.id === requestId)
|
||||||
|
if (index === -1) return null
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const request = current[index]
|
||||||
|
const replies = Array.isArray(request.replies) ? request.replies : []
|
||||||
|
replies.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: now,
|
||||||
|
responderEmail: responderEmail || '',
|
||||||
|
message: replyText
|
||||||
|
})
|
||||||
|
|
||||||
|
current[index] = {
|
||||||
|
...request,
|
||||||
|
status: 'beantwortet',
|
||||||
|
replies,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeContactRequests(current)
|
||||||
|
return current[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContactRequestStatus(requestId, newStatus) {
|
||||||
|
const validStatuses = ['offen', 'beantwortet']
|
||||||
|
if (!validStatuses.includes(newStatus)) return null
|
||||||
|
|
||||||
|
const current = await readContactRequests()
|
||||||
|
const index = current.findIndex((r) => r.id === requestId)
|
||||||
|
if (index === -1) return null
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
current[index] = {
|
||||||
|
...current[index],
|
||||||
|
status: newStatus,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeContactRequests(current)
|
||||||
|
return current[index]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { getServerDataPath } from './paths.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the correct data path for config files
|
* Gets the correct data path for config files
|
||||||
@@ -15,15 +16,7 @@ import path from 'path'
|
|||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant (e.g., 'config.json'), never user input
|
// filename is always a hardcoded constant (e.g., 'config.json'), never user input
|
||||||
function getDataPath(filename) {
|
function getDataPath(filename) {
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
return getServerDataPath(filename)
|
||||||
|
|
||||||
if (isProduction) {
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
return path.join(process.cwd(), '..', 'server', 'data', filename)
|
|
||||||
} else {
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
return path.join(process.cwd(), 'server', 'data', filename)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +41,7 @@ async function loadConfig() {
|
|||||||
* @returns {Array<string>} Email addresses
|
* @returns {Array<string>} Email addresses
|
||||||
*/
|
*/
|
||||||
function getEmailRecipients(data, config) {
|
function getEmailRecipients(data, config) {
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
return ['tsschulz@tsschulz.de']
|
return ['tsschulz@tsschulz.de']
|
||||||
@@ -56,25 +49,29 @@ function getEmailRecipients(data, config) {
|
|||||||
|
|
||||||
const recipients = []
|
const recipients = []
|
||||||
|
|
||||||
// Add 1. Vorsitzender
|
// Config uses a 'vorstand' object with nested roles; collect all emails
|
||||||
if (config.vorsitzender && config.vorsitzender.email) {
|
if (config.vorstand && typeof config.vorstand === 'object') {
|
||||||
recipients.push(config.vorsitzender.email)
|
Object.values(config.vorstand).forEach((member) => {
|
||||||
|
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
|
||||||
|
recipients.push(member.email.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Schriftführer
|
// For minors, also add first trainer email if configured (trainer is an array)
|
||||||
if (config.schriftfuehrer && config.schriftfuehrer.email) {
|
if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) {
|
||||||
recipients.push(config.schriftfuehrer.email)
|
recipients.push(config.trainer[0].email)
|
||||||
}
|
|
||||||
|
|
||||||
// For minors, also add 1. Trainer
|
|
||||||
if (!data.isVolljaehrig && config.trainer && config.trainer.email) {
|
|
||||||
recipients.push(config.trainer.email)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if no recipients found
|
// Fallback if no recipients found
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
|
// Prefer website verantwortlicher if set
|
||||||
|
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
|
||||||
|
recipients.push(config.website.verantwortlicher.email)
|
||||||
|
} else {
|
||||||
recipients.push('tsschulz@tsschulz.de')
|
recipients.push('tsschulz@tsschulz.de')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return recipients
|
return recipients
|
||||||
}
|
}
|
||||||
@@ -94,7 +91,7 @@ function createTransporter() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodemailer.createTransporter({
|
return nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST || 'localhost',
|
host: process.env.SMTP_HOST || 'localhost',
|
||||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
@@ -162,3 +159,60 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
|
||||||
|
* @param {Object} data - { name, email, phone }
|
||||||
|
*/
|
||||||
|
export async function sendRegistrationNotification(data) {
|
||||||
|
try {
|
||||||
|
const config = await loadConfig()
|
||||||
|
const recipients = getEmailRecipients(data, config)
|
||||||
|
|
||||||
|
// Create transporter
|
||||||
|
const transporter = createTransporter()
|
||||||
|
|
||||||
|
// Notify Vorstand/admin
|
||||||
|
const adminSubject = 'Neue Registrierung - Harheimer TC'
|
||||||
|
const adminHtml = `
|
||||||
|
<h2>Neue Registrierung</h2>
|
||||||
|
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Name:</strong> ${data.name}</li>
|
||||||
|
<li><strong>E-Mail:</strong> ${data.email}</li>
|
||||||
|
<li><strong>Telefon:</strong> ${data.phone || 'Nicht angegeben'}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
||||||
|
to: recipients.join(', '),
|
||||||
|
subject: adminSubject,
|
||||||
|
html: adminHtml
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confirmation to user
|
||||||
|
const userSubject = 'Registrierung erhalten - Harheimer TC'
|
||||||
|
const userHtml = `
|
||||||
|
<h2>Registrierung erhalten</h2>
|
||||||
|
<p>Hallo ${data.name},</p>
|
||||||
|
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
|
||||||
|
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
|
||||||
|
<br>
|
||||||
|
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
||||||
|
to: data.email,
|
||||||
|
subject: userSubject,
|
||||||
|
html: userHtml
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, recipients }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('sendRegistrationNotification failed:', error.message || error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function decryptV2GCM(encryptedData, password) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = deriveKey(password, salt)
|
const key = deriveKey(password, salt)
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH })
|
||||||
decipher.setAuthTag(tag)
|
decipher.setAuthTag(tag)
|
||||||
|
|
||||||
const decrypted = Buffer.concat([
|
const decrypted = Buffer.concat([
|
||||||
|
|||||||
@@ -242,10 +242,10 @@ export async function saveMember(memberData) {
|
|||||||
const members = await readMembers()
|
const members = await readMembers()
|
||||||
|
|
||||||
if (memberData.id) {
|
if (memberData.id) {
|
||||||
// Update existing
|
// Update existing manual member if present.
|
||||||
|
// If the ID belongs to a login-only member shown in the merged list,
|
||||||
|
// update an existing manual duplicate or create a manual overlay.
|
||||||
const index = members.findIndex(m => m.id === memberData.id)
|
const index = members.findIndex(m => m.id === memberData.id)
|
||||||
if (index !== -1) {
|
|
||||||
// Check for duplicate (excluding current member)
|
|
||||||
const duplicate = findDuplicateMember(
|
const duplicate = findDuplicateMember(
|
||||||
members.filter(m => m.id !== memberData.id),
|
members.filter(m => m.id !== memberData.id),
|
||||||
memberData.firstName,
|
memberData.firstName,
|
||||||
@@ -253,13 +253,24 @@ export async function saveMember(memberData) {
|
|||||||
memberData.geburtsdatum
|
memberData.geburtsdatum
|
||||||
)
|
)
|
||||||
|
|
||||||
if (duplicate) {
|
if (duplicate && index !== -1) {
|
||||||
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
|
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
members[index] = { ...members[index], ...memberData }
|
members[index] = { ...members[index], ...memberData }
|
||||||
|
} else if (duplicate) {
|
||||||
|
const duplicateIndex = members.findIndex(m => m.id === duplicate.id)
|
||||||
|
members[duplicateIndex] = {
|
||||||
|
...members[duplicateIndex],
|
||||||
|
...memberData,
|
||||||
|
id: members[duplicateIndex].id
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Mitglied nicht gefunden')
|
members.push({
|
||||||
|
...memberData,
|
||||||
|
active: typeof memberData.active === 'boolean' ? memberData.active : true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add new - check for duplicate first
|
// Add new - check for duplicate first
|
||||||
|
|||||||
@@ -236,6 +236,22 @@ export async function getRecipientsByGroup(targetGroup) {
|
|||||||
email: m.email,
|
email: m.email,
|
||||||
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Zusätzlich aktive Trainer aus users.json anschreiben
|
||||||
|
users
|
||||||
|
.filter(u => {
|
||||||
|
if (!u.active || !u.email || !u.email.trim()) return false
|
||||||
|
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
|
||||||
|
return roles.includes('trainer')
|
||||||
|
})
|
||||||
|
.forEach(u => {
|
||||||
|
if (!recipients.find(r => r.email.toLowerCase().trim() === u.email.toLowerCase().trim())) {
|
||||||
|
recipients.push({
|
||||||
|
email: u.email.trim(),
|
||||||
|
name: u.name || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'mannschaftsspieler':
|
case 'mannschaftsspieler':
|
||||||
|
|||||||
32
server/utils/paths.js
Normal file
32
server/utils/paths.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
function uniqueCandidates(candidates) {
|
||||||
|
return [...new Set(candidates.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasServerDataDir(root) {
|
||||||
|
const normalizedRoot = String(root || '').replace(/\/+$/, '')
|
||||||
|
return fs.existsSync(`${normalizedRoot}/server/data`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectRoot() {
|
||||||
|
const envRoot = process.env.APP_ROOT ? process.env.APP_ROOT.trim() : ''
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const parent = path.resolve(cwd, '..')
|
||||||
|
|
||||||
|
const candidates = uniqueCandidates([envRoot, cwd, parent])
|
||||||
|
for (const root of candidates) {
|
||||||
|
if (hasServerDataDir(root)) return root
|
||||||
|
}
|
||||||
|
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectPath(...segments) {
|
||||||
|
return path.join(resolveProjectRoot(), ...segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerDataPath(...segments) {
|
||||||
|
return getProjectPath('server', 'data', ...segments)
|
||||||
|
}
|
||||||
@@ -2,20 +2,16 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Use internal server/data directory for Termine CSV to avoid writing to public/
|
||||||
// filename is always a hardcoded constant (e.g., 'termine.csv'), never user input
|
|
||||||
const getDataPath = (filename) => {
|
const getDataPath = (filename) => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
|
|
||||||
// In production (.output/server), working dir is .output
|
// Prefer server/data in both production and development
|
||||||
|
// e.g. project-root/server/data/termine.csv or .output/server/data/termine.csv
|
||||||
if (cwd.endsWith('.output')) {
|
if (cwd.endsWith('.output')) {
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
return `${cwd}/../server/data/${filename}`
|
||||||
return path.join(cwd, '../public/data', filename)
|
|
||||||
}
|
}
|
||||||
|
return `${cwd}/server/data/${filename}`
|
||||||
// In development, working dir is project root
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
return path.join(cwd, 'public/data', filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINE_FILE = getDataPath('termine.csv')
|
const TERMINE_FILE = getDataPath('termine.csv')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createEvent, mockSuccessReadBody } from './setup'
|
import { createEvent, mockSuccessReadBody } from './setup'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
vi.mock('../server/utils/auth.js', () => {
|
vi.mock('../server/utils/auth.js', () => {
|
||||||
return {
|
return {
|
||||||
@@ -60,8 +61,14 @@ import logoutHandler from '../server/api/auth/logout.post.js'
|
|||||||
import registerHandler from '../server/api/auth/register.post.js'
|
import registerHandler from '../server/api/auth/register.post.js'
|
||||||
import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
|
import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
|
||||||
import statusHandler from '../server/api/auth/status.get.js'
|
import statusHandler from '../server/api/auth/status.get.js'
|
||||||
|
import versionHandler from '../server/api/app/version.get.js'
|
||||||
|
|
||||||
describe('Auth API Endpoints', () => {
|
describe('Auth API Endpoints', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.NODE_ENV
|
||||||
|
delete process.env.APP_ENV
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setze SMTP-Credentials für Tests
|
// Setze SMTP-Credentials für Tests
|
||||||
process.env.SMTP_USER = 'test@example.com'
|
process.env.SMTP_USER = 'test@example.com'
|
||||||
@@ -134,15 +141,29 @@ describe('Auth API Endpoints', () => {
|
|||||||
|
|
||||||
it('verhindert doppelte Benutzer', async () => {
|
it('verhindert doppelte Benutzer', async () => {
|
||||||
const event = createEvent()
|
const event = createEvent()
|
||||||
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678' })
|
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', geburtsdatum: '2000-01-01' })
|
||||||
authUtils.readUsers.mockResolvedValue([{ email: 'max@example.com' }])
|
authUtils.readUsers.mockResolvedValue([{ email: 'max@example.com' }])
|
||||||
|
|
||||||
await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('verlangt Geburtsdatum bei Registrierung', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678' })
|
||||||
|
|
||||||
|
await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
it('legt Benutzer an und versendet E-Mails', async () => {
|
it('legt Benutzer an und versendet E-Mails', async () => {
|
||||||
const event = createEvent()
|
const event = createEvent()
|
||||||
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', phone: '123' })
|
mockSuccessReadBody({
|
||||||
|
name: 'Max',
|
||||||
|
email: 'max@example.com',
|
||||||
|
password: '12345678',
|
||||||
|
phone: '123',
|
||||||
|
geburtsdatum: '2000-01-01',
|
||||||
|
visibility: { showBirthday: false }
|
||||||
|
})
|
||||||
authUtils.readUsers.mockResolvedValue([])
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
authUtils.hashPassword.mockResolvedValue('hashed')
|
authUtils.hashPassword.mockResolvedValue('hashed')
|
||||||
authUtils.writeUsers.mockResolvedValue(true)
|
authUtils.writeUsers.mockResolvedValue(true)
|
||||||
@@ -151,8 +172,36 @@ describe('Auth API Endpoints', () => {
|
|||||||
|
|
||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(authUtils.writeUsers).toHaveBeenCalled()
|
expect(authUtils.writeUsers).toHaveBeenCalled()
|
||||||
|
expect(authUtils.writeUsers.mock.calls[0][0][0]).toMatchObject({
|
||||||
|
geburtsdatum: '2000-01-01',
|
||||||
|
visibility: { showBirthday: false }
|
||||||
|
})
|
||||||
expect(nodemailer.default.createTransport).toHaveBeenCalled()
|
expect(nodemailer.default.createTransport).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.APP_ENV = 'test'
|
||||||
|
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({
|
||||||
|
name: 'Max',
|
||||||
|
email: 'max@example.com',
|
||||||
|
password: '12345678',
|
||||||
|
phone: '123',
|
||||||
|
geburtsdatum: '2000-01-01'
|
||||||
|
})
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
authUtils.hashPassword.mockResolvedValue('hashed')
|
||||||
|
authUtils.writeUsers.mockResolvedValue(true)
|
||||||
|
|
||||||
|
await registerHandler(event)
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
expect(transporter.sendMail).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
to: 'tsschulz@tsschulz.de'
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('POST /api/auth/reset-password', () => {
|
describe('POST /api/auth/reset-password', () => {
|
||||||
@@ -194,4 +243,22 @@ describe('Auth API Endpoints', () => {
|
|||||||
expect(response.user).toMatchObject({ id: '1' })
|
expect(response.user).toMatchObject({ id: '1' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GET /api/app/version', () => {
|
||||||
|
it('verlangt Login', async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
|
||||||
|
await expect(versionHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert eingeloggten Benutzern die package.json-Version', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', email: 'user@example.com', roles: ['mitglied'] })
|
||||||
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
||||||
|
|
||||||
|
const response = await versionHandler(event)
|
||||||
|
|
||||||
|
expect(response.version).toBe(packageJson.version)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,38 @@ vi.mock('child_process', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('util', () => ({
|
vi.mock('util', () => ({
|
||||||
promisify: () => () => Promise.resolve({ stdout: 'PDF Inhalt', stderr: '' })
|
promisify: () => () => Promise.resolve({
|
||||||
|
stdout: `§ 1 Name und Sitz
|
||||||
|
Der Verein führt den Namen Harheimer TC.
|
||||||
|
|
||||||
|
§ 2 Zweck
|
||||||
|
Der Verein verfolgt ausschließlich und unmittelbar gemeinnützige Zwecke.
|
||||||
|
|
||||||
|
§ 3 Mitgliedschaft
|
||||||
|
(1) Mitglied kann jede natürliche Person werden.
|
||||||
|
(2) Über die Aufnahme entscheidet der Vorstand.
|
||||||
|
|
||||||
|
§ 4 Beiträge
|
||||||
|
Die Mitglieder zahlen Beiträge nach Maßgabe der Beitragsordnung.
|
||||||
|
|
||||||
|
§ 5 Vorstand
|
||||||
|
Der Vorstand besteht aus dem Vorsitzenden, dem Schriftführer und dem Kassenwart.
|
||||||
|
|
||||||
|
§ 6 Schlussbestimmungen
|
||||||
|
Diese Satzung tritt mit Beschluss der Mitgliederversammlung in Kraft.
|
||||||
|
|
||||||
|
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
|
||||||
|
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
|
||||||
|
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
|
||||||
|
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
|
||||||
|
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
|
||||||
|
`,
|
||||||
|
stderr: ''
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/upload-validation.js', () => ({
|
||||||
|
assertPdfMagicHeader: vi.fn().mockResolvedValue(undefined)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import saveCsvHandler from '../server/api/cms/save-csv.post.js'
|
import saveCsvHandler from '../server/api/cms/save-csv.post.js'
|
||||||
@@ -67,11 +98,26 @@ describe('CMS File Endpoints', () => {
|
|||||||
mockSuccessReadBody({ filename: 'mannschaften.csv', content: 'data' })
|
mockSuccessReadBody({ filename: 'mannschaften.csv', content: 'data' })
|
||||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
|
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
|
||||||
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
|
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'rename').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'stat').mockResolvedValue({ size: Buffer.byteLength('data', 'utf8') } as any)
|
||||||
|
|
||||||
const response = await saveCsvHandler(event)
|
const response = await saveCsvHandler(event)
|
||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(fs.writeFile).toHaveBeenCalled()
|
expect(fs.writeFile).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('erlaubt vorstand beim CSV-Speichern', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
mockSuccessReadBody({ filename: 'spielplan.csv', content: 'kopf;wert' })
|
||||||
|
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'rename').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'stat').mockResolvedValue({ size: Buffer.byteLength('kopf;wert', 'utf8') } as any)
|
||||||
|
getUserFromToken.mockResolvedValue({ id: 'vorstand', role: 'vorstand' })
|
||||||
|
|
||||||
|
const response = await saveCsvHandler(event)
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('POST /api/cms/upload-spielplan-pdf', () => {
|
describe('POST /api/cms/upload-spielplan-pdf', () => {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ vi.mock('../server/utils/auth.js', () => ({
|
|||||||
getUserFromToken: vi.fn(),
|
getUserFromToken: vi.fn(),
|
||||||
readUsers: vi.fn(),
|
readUsers: vi.fn(),
|
||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
|
hasRole: vi.fn((user, role) => {
|
||||||
|
if (!user) return false
|
||||||
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
return userRoles.includes(role)
|
||||||
|
}),
|
||||||
hasAnyRole: vi.fn((user, ...roles) => {
|
hasAnyRole: vi.fn((user, ...roles) => {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ vi.mock('../server/utils/auth.js', () => ({
|
|||||||
|
|
||||||
vi.mock('sharp', () => ({
|
vi.mock('sharp', () => ({
|
||||||
default: vi.fn(() => ({
|
default: vi.fn(() => ({
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 1200, height: 800 }),
|
||||||
resize: vi.fn().mockReturnThis(),
|
resize: vi.fn().mockReturnThis(),
|
||||||
rotate: vi.fn().mockReturnThis(),
|
rotate: vi.fn().mockReturnThis(),
|
||||||
toFile: vi.fn().mockResolvedValue({}),
|
toFile: vi.fn().mockResolvedValue({}),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createEvent, mockSuccessReadBody } from './setup'
|
import { createEvent, mockSuccessReadBody } from './setup'
|
||||||
|
|
||||||
vi.mock('../server/utils/auth.js', () => ({
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
verifyToken: vi.fn(),
|
verifyToken: vi.fn(),
|
||||||
getUserById: vi.fn(),
|
getUserById: vi.fn(),
|
||||||
|
getUserFromToken: vi.fn(),
|
||||||
readUsers: vi.fn(),
|
readUsers: vi.fn(),
|
||||||
readMembers: vi.fn(),
|
readMembers: vi.fn(),
|
||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
@@ -24,6 +26,11 @@ vi.mock('../server/utils/auth.js', () => ({
|
|||||||
if (!user) return false
|
if (!user) return false
|
||||||
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
return roles.some(r => userRoles.includes(r))
|
return roles.some(r => userRoles.includes(r))
|
||||||
|
}),
|
||||||
|
hasRole: vi.fn((user, role) => {
|
||||||
|
if (!user) return false
|
||||||
|
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
|
||||||
|
return userRoles.includes(role)
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -58,16 +65,35 @@ describe('Members API Endpoints', () => {
|
|||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
memberUtils.readMembers.mockResolvedValue([
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de' }
|
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', hasHallKey: true }
|
||||||
])
|
])
|
||||||
authUtils.readUsers.mockResolvedValue([
|
authUtils.readUsers.mockResolvedValue([
|
||||||
{ id: 'u1', name: 'Ben Nutzer', email: 'ben@club.de', role: 'mitglied', active: true }
|
{ id: 'u1', name: 'Ben Nutzer', email: 'ben@club.de', role: 'mitglied', active: true }
|
||||||
])
|
])
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied' })
|
||||||
|
|
||||||
const response = await membersGetHandler(event)
|
const response = await membersGetHandler(event)
|
||||||
|
|
||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(response.members).toHaveLength(2)
|
expect(response.members).toHaveLength(2)
|
||||||
|
expect(response.members[0]).toHaveProperty('hasHallKey', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt Legacy-Mitglieder ohne active-Flag weiterhin an', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
|
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01' },
|
||||||
|
{ id: 'm2', firstName: 'Offen', lastName: 'Antrag', geburtsdatum: '2001-01-01', status: 'pending' }
|
||||||
|
])
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied' })
|
||||||
|
|
||||||
|
const response = await membersGetHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(response.members).toHaveLength(1)
|
||||||
|
expect(response.members[0].name).toBe('Anna Muster')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,7 +102,8 @@ describe('Members API Endpoints', () => {
|
|||||||
firstName: 'Lisa',
|
firstName: 'Lisa',
|
||||||
lastName: 'Beispiel',
|
lastName: 'Beispiel',
|
||||||
geburtsdatum: '2000-01-01',
|
geburtsdatum: '2000-01-01',
|
||||||
email: 'lisa@example.com'
|
email: 'lisa@example.com',
|
||||||
|
hasHallKey: true
|
||||||
}
|
}
|
||||||
|
|
||||||
it('verweigert Zugriff ohne Token', async () => {
|
it('verweigert Zugriff ohne Token', async () => {
|
||||||
@@ -88,8 +115,7 @@ describe('Members API Endpoints', () => {
|
|||||||
it('verlangt Admin- oder Vorstand-Rolle', async () => {
|
it('verlangt Admin- oder Vorstand-Rolle', async () => {
|
||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
mockSuccessReadBody(baseBody)
|
mockSuccessReadBody(baseBody)
|
||||||
authUtils.verifyToken.mockReturnValue({ id: '2' })
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'mitglied' })
|
||||||
authUtils.getUserById.mockResolvedValue({ id: '2', role: 'mitglied' })
|
|
||||||
|
|
||||||
await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
})
|
})
|
||||||
@@ -97,8 +123,7 @@ describe('Members API Endpoints', () => {
|
|||||||
it('gibt 409 bei Duplikaten zurück', async () => {
|
it('gibt 409 bei Duplikaten zurück', async () => {
|
||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
mockSuccessReadBody(baseBody)
|
mockSuccessReadBody(baseBody)
|
||||||
authUtils.verifyToken.mockReturnValue({ id: '2' })
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
|
||||||
authUtils.getUserById.mockResolvedValue({ id: '2', role: 'admin' })
|
|
||||||
memberUtils.saveMember.mockRejectedValue(new Error('existiert bereits'))
|
memberUtils.saveMember.mockRejectedValue(new Error('existiert bereits'))
|
||||||
|
|
||||||
await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 409 })
|
||||||
@@ -107,13 +132,43 @@ describe('Members API Endpoints', () => {
|
|||||||
it('speichert Mitglied erfolgreich', async () => {
|
it('speichert Mitglied erfolgreich', async () => {
|
||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
mockSuccessReadBody(baseBody)
|
mockSuccessReadBody(baseBody)
|
||||||
authUtils.verifyToken.mockReturnValue({ id: '2' })
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
|
||||||
authUtils.getUserById.mockResolvedValue({ id: '2', role: 'admin' })
|
|
||||||
memberUtils.saveMember.mockResolvedValue(true)
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
const response = await membersPostHandler(event)
|
const response = await membersPostHandler(event)
|
||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(memberUtils.saveMember).toHaveBeenCalled()
|
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
hasHallKey: true
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erlaubt vorstand beim Speichern', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
mockSuccessReadBody(baseBody)
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
|
||||||
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await membersPostHandler(event)
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erlaubt Updates von Altdaten ohne Geburtsdatum', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
mockSuccessReadBody({
|
||||||
|
id: 'legacy-1',
|
||||||
|
firstName: 'Lisa',
|
||||||
|
lastName: 'Beispiel',
|
||||||
|
email: 'lisa@example.com'
|
||||||
|
})
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
|
||||||
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await membersPostHandler(event)
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
id: 'legacy-1',
|
||||||
|
geburtsdatum: ''
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createEvent, mockSuccessReadBody } from './setup'
|
import { createEvent, mockSuccessReadBody } from './setup'
|
||||||
import fsPromises from 'fs/promises'
|
import fsPromises from 'fs/promises'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
@@ -26,6 +26,11 @@ import termineHandler from '../server/api/termine.get.js'
|
|||||||
import spielplaeneHandler from '../server/api/spielplaene.get.js'
|
import spielplaeneHandler from '../server/api/spielplaene.get.js'
|
||||||
|
|
||||||
describe('Öffentliche API-Endpunkte', () => {
|
describe('Öffentliche API-Endpunkte', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.NODE_ENV
|
||||||
|
delete process.env.APP_ENV
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setze SMTP-Credentials für Tests
|
// Setze SMTP-Credentials für Tests
|
||||||
process.env.SMTP_USER = 'test@example.com'
|
process.env.SMTP_USER = 'test@example.com'
|
||||||
@@ -58,6 +63,21 @@ describe('Öffentliche API-Endpunkte', () => {
|
|||||||
expect(response.success).toBe(true)
|
expect(response.success).toBe(true)
|
||||||
expect(nodemailer.default.createTransport).toHaveBeenCalled()
|
expect(nodemailer.default.createTransport).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sendet in Testumgebung nicht an Vorstand-Empfänger', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.APP_ENV = 'test'
|
||||||
|
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
|
||||||
|
|
||||||
|
await contactHandler(event)
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
to: 'tsschulz@tsschulz.de'
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('GET /api/galerie', () => {
|
describe('GET /api/galerie', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user