Refactor environment configuration for local development; update SMTP settings and add JWT secret, encryption key, and debug options. Enhance Nuxt configuration for development server and runtime settings. Introduce new membership application form with validation and PDF generation functionality. Update footer and navigation components to include new membership links. Revise user and session data in JSON files.

This commit is contained in:
Torsten Schulz (local)
2025-10-23 01:31:45 +02:00
parent de73ceb62f
commit 7cd39bb452
43 changed files with 3350 additions and 457 deletions

View File

@@ -1,5 +1,5 @@
{
"date": "2025-10-22T13:11:03.062Z",
"date": "2025-10-22T20:59:39.715Z",
"preset": "node-server",
"framework": {
"name": "nuxt",

View File

@@ -1 +1 @@
{"id":"e0e6f75d-0441-44da-af83-0c8ad93a6ce1","timestamp":1761138655435}
{"id":"45b20f41-8a8a-4096-94d4-3b6174a1f364","timestamp":1761166771198}

View File

@@ -87,6 +87,17 @@ const client_manifest = {
"node_modules/nuxt/dist/app/entry.js"
]
},
"_C_U-NUAd.js": {
"resourceType": "script",
"module": true,
"prefetch": true,
"preload": true,
"file": "C_U-NUAd.js",
"name": "composables",
"imports": [
"node_modules/nuxt/dist/app/entry.js"
]
},
"_CkzaQq3X.js": {
"resourceType": "script",
"module": true,
@@ -216,17 +227,6 @@ const client_manifest = {
"node_modules/nuxt/dist/app/entry.js"
]
},
"_Qy3ajxTk.js": {
"resourceType": "script",
"module": true,
"prefetch": true,
"preload": true,
"file": "Qy3ajxTk.js",
"name": "composables",
"imports": [
"node_modules/nuxt/dist/app/entry.js"
]
},
"_R6Iy1jPP.js": {
"resourceType": "script",
"module": true,
@@ -294,7 +294,7 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "D-Zvexq_.js",
"file": "63DEGUI_.js",
"name": "auth",
"src": "middleware/auth.js",
"isDynamicEntry": true,
@@ -307,14 +307,14 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "ZrOCUSmD.js",
"file": "C9SglkVL.js",
"name": "error-404",
"src": "node_modules/nuxt/dist/app/components/error-404.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_DlAUqK2U.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
],
"css": []
},
@@ -329,13 +329,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BVRiFo7f.js",
"file": "CW9krljs.js",
"name": "error-500",
"src": "node_modules/nuxt/dist/app/components/error-500.vue",
"isDynamicEntry": true,
"imports": [
"_DlAUqK2U.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
],
"css": []
@@ -351,7 +351,7 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CmbXHhwn.js",
"file": "KrCelFbA.js",
"name": "entry",
"src": "node_modules/nuxt/dist/app/entry.js",
"isEntry": true,
@@ -361,14 +361,14 @@ const client_manifest = {
"node_modules/nuxt/dist/app/components/error-500.vue"
],
"css": [
"entry.DX4WLXSP.css"
"entry.BpzTEo9t.css"
],
"assets": [
"Harheimer TC.CKfYAfp1.svg"
]
},
"entry.DX4WLXSP.css": {
"file": "entry.DX4WLXSP.css",
"entry.BpzTEo9t.css": {
"file": "entry.BpzTEo9t.css",
"resourceType": "style",
"prefetch": true,
"preload": true
@@ -384,13 +384,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "D7LlSYAz.js",
"file": "C3627_Er.js",
"name": "anlagen",
"src": "pages/anlagen.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/cms/benutzer.vue": {
@@ -398,13 +398,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CtoHBsGq.js",
"file": "CzgMfPlN.js",
"name": "benutzer",
"src": "pages/cms/benutzer.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_C8kQt0fa.js",
"_DaSgy0Cl.js"
]
@@ -414,13 +414,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BlTx75vv.js",
"file": "CIPPGKqt.js",
"name": "einstellungen",
"src": "pages/cms/einstellungen.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_CUq_0rkE.js",
"_DUm-savV.js",
"_YJHbYJtA.js",
@@ -436,12 +436,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "G3v2TcOj.js",
"file": "DxKvlgrz.js",
"name": "geschichte",
"src": "pages/cms/geschichte.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -450,31 +450,45 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CcTzWjtb.js",
"file": "CfIOs31W.js",
"name": "index",
"src": "pages/cms/index.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_DAACT36i.js",
"_YJHbYJtA.js",
"_DkeYb0_S.js",
"_DUm-savV.js"
]
},
"pages/cms/mitgliedschaftsantraege.vue": {
"resourceType": "script",
"module": true,
"prefetch": true,
"preload": true,
"file": "DUL8f07u.js",
"name": "mitgliedschaftsantraege",
"src": "pages/cms/mitgliedschaftsantraege.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_C_U-NUAd.js"
]
},
"pages/cms/satzung.vue": {
"resourceType": "script",
"module": true,
"prefetch": true,
"preload": true,
"file": "CJOOzUp1.js",
"file": "DzTYmaPw.js",
"name": "satzung",
"src": "pages/cms/satzung.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/cms/termine.vue": {
@@ -482,13 +496,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BP5itt78.js",
"file": "DDYB2ueJ.js",
"name": "termine",
"src": "pages/cms/termine.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_R6Iy1jPP.js",
"_CUq_0rkE.js",
"_FF_cyd6S.js",
@@ -500,12 +514,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "C8Sqpv2D.js",
"file": "CPS6rtgg.js",
"name": "tt-regeln",
"src": "pages/cms/tt-regeln.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -514,12 +528,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CfC7HTR7.js",
"file": "lhUIkRXy.js",
"name": "ueber-uns",
"src": "pages/cms/ueber-uns.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -528,13 +542,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "Bu6whu8C.js",
"file": "CJ4iaRop.js",
"name": "galerie",
"src": "pages/galerie.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/geschichte.vue": {
@@ -542,12 +556,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "Bqdk1y3w.js",
"file": "B23trXK4.js",
"name": "geschichte",
"src": "pages/geschichte.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -556,13 +570,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DU1f7CIy.js",
"file": "B2n3gjaF.js",
"name": "impressum",
"src": "pages/impressum.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_BteKZQ9T.js",
"_Czdc6-TI.js"
]
@@ -598,7 +612,7 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "5IKOyEE8.js",
"file": "B_w5gbrC.js",
"name": "kontakt",
"src": "pages/kontakt.vue",
"isDynamicEntry": true,
@@ -608,7 +622,7 @@ const client_manifest = {
"_DdHhmCne.js",
"_BC4PNGtJ.js",
"_C8kQt0fa.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/login.vue": {
@@ -616,13 +630,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "TnZylaYP.js",
"file": "DCC-mN0A.js",
"name": "login",
"src": "pages/login.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_C8kQt0fa.js",
"_DaSgy0Cl.js",
"_CUq_0rkE.js"
@@ -633,13 +647,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DrS211Qg.js",
"file": "BKO8ChwC.js",
"name": "_slug_",
"src": "pages/mannschaften/[slug].vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_jVj3QaoK.js"
]
},
@@ -648,13 +662,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "48ve60fm.js",
"file": "LPF2GIYR.js",
"name": "damen",
"src": "pages/mannschaften/damen.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/mannschaften/herren.vue": {
@@ -662,12 +676,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BEK-x74F.js",
"file": "sVyj_WZX.js",
"name": "herren",
"src": "pages/mannschaften/herren.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -676,7 +690,7 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CGF4oWdy.js",
"file": "Bivc7aFF.js",
"name": "index",
"src": "pages/mannschaften/index.vue",
"isDynamicEntry": true,
@@ -684,7 +698,7 @@ const client_manifest = {
"node_modules/nuxt/dist/app/entry.js",
"_jVj3QaoK.js",
"_DkeYb0_S.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/mannschaften/jugend.vue": {
@@ -692,13 +706,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BOTV4xuv.js",
"file": "BZLaJF8o.js",
"name": "jugend",
"src": "pages/mannschaften/jugend.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/mannschaften/spielplaene.vue": {
@@ -706,12 +720,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DmeaandR.js",
"file": "S5xR3JqC.js",
"name": "spielplaene",
"src": "pages/mannschaften/spielplaene.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_BteKZQ9T.js",
"node_modules/nuxt/dist/app/entry.js",
"_Cx4UcKGu.js"
@@ -722,13 +736,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DBoACobG.js",
"file": "rgKTeSYE.js",
"name": "index",
"src": "pages/mitgliederbereich/index.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_DkeYb0_S.js",
"_DAACT36i.js",
"_DaSgy0Cl.js"
@@ -739,13 +753,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CmqI4hAm.js",
"file": "Cx_3U4cr.js",
"name": "mitglieder",
"src": "pages/mitgliederbereich/mitglieder.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_oN0_bS6A.js",
"_CUq_0rkE.js",
"_6EY4_GXp.js",
@@ -762,13 +776,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DNeqJiJt.js",
"file": "pO5XAgdL.js",
"name": "news",
"src": "pages/mitgliederbereich/news.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_R6Iy1jPP.js",
"_CUq_0rkE.js",
"_KxVBmS-6.js",
@@ -784,13 +798,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "C7eIgk1J.js",
"file": "CDaMcsB4.js",
"name": "profil",
"src": "pages/mitgliederbereich/profil.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_CUq_0rkE.js",
"_C8kQt0fa.js",
"_DaSgy0Cl.js"
@@ -801,7 +815,7 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "zfimBmFS.js",
"file": "BlI1POL_.js",
"name": "mitgliedschaft",
"src": "pages/mitgliedschaft.vue",
"isDynamicEntry": true,
@@ -813,7 +827,7 @@ const client_manifest = {
"_DkeYb0_S.js",
"_BteKZQ9T.js",
"_Czdc6-TI.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/passwort-vergessen.vue": {
@@ -821,13 +835,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "D8fhH48e.js",
"file": "DYva3pFh.js",
"name": "passwort-vergessen",
"src": "pages/passwort-vergessen.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_C8kQt0fa.js",
"_DaSgy0Cl.js",
"_CUq_0rkE.js"
@@ -838,13 +852,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DCJCzUpS.js",
"file": "DeoKPvBx.js",
"name": "registrieren",
"src": "pages/registrieren.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_C8kQt0fa.js",
"_DaSgy0Cl.js",
"_CUq_0rkE.js"
@@ -855,12 +869,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "B45eiNl0.js",
"file": "CTuRK0lH.js",
"name": "satzung",
"src": "pages/satzung.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_BteKZQ9T.js",
"node_modules/nuxt/dist/app/entry.js"
]
@@ -870,12 +884,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "98faI9Ca.js",
"file": "DjW4xBSP.js",
"name": "spielsysteme",
"src": "pages/spielsysteme.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_DkeYb0_S.js",
"_YJHbYJtA.js",
"node_modules/nuxt/dist/app/entry.js",
@@ -890,12 +904,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "CNecvSw-.js",
"file": "BqZP274n.js",
"name": "termine",
"src": "pages/termine.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_YJHbYJtA.js",
"node_modules/nuxt/dist/app/entry.js"
]
@@ -905,13 +919,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BejXl4Ry.js",
"file": "DXBW1M-0.js",
"name": "anfaenger",
"src": "pages/training/anfaenger.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_DaSgy0Cl.js"
]
},
@@ -920,13 +934,13 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BKvBGWYj.js",
"file": "Q0RpvB7T.js",
"name": "index",
"src": "pages/training/index.vue",
"isDynamicEntry": true,
"imports": [
"node_modules/nuxt/dist/app/entry.js",
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_C5SyyWEb.js"
]
},
@@ -935,12 +949,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "B7Xj1aAc.js",
"file": "BkzaDkuN.js",
"name": "trainer",
"src": "pages/training/trainer.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -949,12 +963,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "DInduCQ0.js",
"file": "DjcJk1g8.js",
"name": "tt-regeln",
"src": "pages/tt-regeln.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_KxVBmS-6.js",
"_BteKZQ9T.js",
"_B4mSF5Ac.js",
@@ -969,7 +983,7 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BLO7WCWA.js",
"file": "B0zS1aUD.js",
"name": "ueber-uns",
"src": "pages/ueber-uns.vue",
"isDynamicEntry": true,
@@ -977,7 +991,7 @@ const client_manifest = {
"node_modules/nuxt/dist/app/entry.js",
"_CWEkTB1z.js",
"_B4mSF5Ac.js",
"_Qy3ajxTk.js"
"_C_U-NUAd.js"
]
},
"pages/verein/geschichte.vue": {
@@ -985,12 +999,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "B1qBTDuC.js",
"file": "KBGCy6kF.js",
"name": "geschichte",
"src": "pages/verein/geschichte.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -999,12 +1013,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BZfUaD4r.js",
"file": "BKEdaV_4.js",
"name": "satzung",
"src": "pages/verein/satzung.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -1013,12 +1027,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "D565ijOU.js",
"file": "Bh7iK8Ct.js",
"name": "tt-regeln",
"src": "pages/verein/tt-regeln.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -1027,12 +1041,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "NvbKvUY5.js",
"file": "3DraxWaO.js",
"name": "ueber-uns",
"src": "pages/verein/ueber-uns.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
},
@@ -1041,12 +1055,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "BwIK_w7L.js",
"file": "DF1dYnic.js",
"name": "vereinsmeisterschaften",
"src": "pages/vereinsmeisterschaften.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"_CrCcIvVp.js",
"node_modules/nuxt/dist/app/entry.js"
]
@@ -1056,12 +1070,12 @@ const client_manifest = {
"module": true,
"prefetch": true,
"preload": true,
"file": "B78Xcyyd.js",
"file": "BajRASxo.js",
"name": "vorstand",
"src": "pages/vorstand.vue",
"isDynamicEntry": true,
"imports": [
"_Qy3ajxTk.js",
"_C_U-NUAd.js",
"node_modules/nuxt/dist/app/entry.js"
]
}

View File

@@ -1,5 +1,5 @@
import process from 'node:process';globalThis._importMeta_=globalThis._importMeta_||{url:"file:///_entry.js",env:process.env};import { defineComponent, shallowRef, h, resolveComponent, hasInjectionContext, inject, computed, getCurrentInstance, createElementBlock, provide, cloneVNode, ref, Suspense, Fragment, createApp, shallowReactive, mergeProps, withCtx, createVNode, createTextVNode, unref, toDisplayString, toRef, onErrorCaptured, onServerPrefetch, resolveDynamicComponent, reactive, effectScope, isReadonly, isRef, isShallow, isReactive, toRaw, defineAsyncComponent, getCurrentScope, useSSRContext } from 'vue';
import { p as parseQuery, c as createError$1, o as hasProtocol, q as isScriptProtocol, m as joinURL, w as withQuery, t as sanitizeStatusCode, v as withTrailingSlash, x as withoutTrailingSlash, y as getContext, $ as $fetch$1, z as createHooks, A as executeAsync, B as toRouteMatcher, C as createRouter$1, D as defu } from '../nitro/nitro.mjs';
import { q as parseQuery, c as createError$1, t as hasProtocol, v as isScriptProtocol, o as joinURL, w as withQuery, x as sanitizeStatusCode, y as withTrailingSlash, z as withoutTrailingSlash, A as getContext, $ as $fetch$1, B as createHooks, C as executeAsync, D as toRouteMatcher, E as createRouter$1, F as defu } from '../nitro/nitro.mjs';
import { b as baseURL } from '../routes/renderer.mjs';
import { defineStore, createPinia, setActivePinia, shouldHydrate } from 'pinia';
import { RouterView, useRoute as useRoute$1, useRouter as useRouter$1, createMemoryHistory, createRouter, START_LOCATION } from 'vue-router';
@@ -538,7 +538,7 @@ const _routes = [
{
name: "mitgliedschaft",
path: "/mitgliedschaft",
component: () => import('./mitgliedschaft-C0k1hAkJ.mjs')
component: () => import('./mitgliedschaft-ChvgsgBw.mjs')
},
{
name: "training",
@@ -639,6 +639,11 @@ const _routes = [
meta: { ...__nuxt_page_meta$1 || {}, ...{ "middleware": "auth" } },
component: () => import('./profil-Dm_3uuTL.mjs')
},
{
name: "cms-mitgliedschaftsantraege",
path: "/cms/mitgliedschaftsantraege",
component: () => import('./mitgliedschaftsantraege-UTBvvWQL.mjs')
},
{
name: "mitgliederbereich-mitglieder",
path: "/mitgliederbereich/mitglieder",
@@ -2064,6 +2069,22 @@ const _sfc_main$4 = {
}),
_: 1
}, _parent));
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/cms/mitgliedschaftsantraege",
onClick: ($event) => showCmsDropdown.value = false,
class: "block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
}, {
default: withCtx((_, _push2, _parent2, _scopeId) => {
if (_push2) {
_push2(` Mitgliedschaftsanträge `);
} else {
return [
createTextVNode(" Mitgliedschaftsanträge ")
];
}
}),
_: 1
}, _parent));
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/cms/benutzer",
onClick: ($event) => showCmsDropdown.value = false,
@@ -2647,6 +2668,22 @@ const _sfc_main$4 = {
}),
_: 1
}, _parent));
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/cms/mitgliedschaftsantraege",
onClick: ($event) => isMobileMenuOpen.value = false,
class: "block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
}, {
default: withCtx((_, _push2, _parent2, _scopeId) => {
if (_push2) {
_push2(` Mitgliedschaftsanträge `);
} else {
return [
createTextVNode(" Mitgliedschaftsanträge ")
];
}
}),
_: 1
}, _parent));
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/cms/benutzer",
onClick: ($event) => isMobileMenuOpen.value = false,
@@ -2718,7 +2755,7 @@ const _sfc_main$3 = {
computed(() => authStore.isAdmin);
return (_ctx, _push, _parent, _attrs) => {
const _component_NuxtLink = __nuxt_component_0$1;
_push(`<footer${ssrRenderAttrs(mergeProps({ class: "fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl" }, _attrs))}><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3"><div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0"><p class="text-sm text-gray-400"> © ${ssrInterpolate(unref(currentYear))} Harheimer TC </p><div class="flex items-center space-x-6 text-sm relative">`);
_push(`<footer${ssrRenderAttrs(mergeProps({ class: "fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl" }, _attrs))}><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3"><div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0"><p class="text-sm text-gray-400"> © ${ssrInterpolate(unref(currentYear))} Harheimer TC 1954 e.V. </p><div class="flex items-center space-x-6 text-sm relative">`);
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/impressum",
class: "text-gray-400 hover:text-primary-400 transition-colors"

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { d as defineEventHandler, r as readBody, c as createError } from '../../../nitro/nitro.mjs';
import { r as readUsers, h as hashPassword, w as writeUsers } from '../../../_/auth.mjs';
import nodemailer from 'nodemailer';
import require$$1 from 'crypto';
import crypto from 'crypto';
import 'node:http';
import 'node:https';
import 'node:events';
@@ -33,7 +33,7 @@ const resetPassword_post = defineEventHandler(async (event) => {
message: "Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet."
};
}
const tempPassword = require$$1.randomBytes(8).toString("hex");
const tempPassword = crypto.randomBytes(8).toString("hex");
const hashedPassword = await hashPassword(tempPassword);
user.password = hashedPassword;
user.passwordResetRequired = true;

View File

@@ -1 +1 @@
{"version":3,"file":"reset-password.post.mjs","sources":["../../../../../../server/api/auth/reset-password.post.js"],"sourcesContent":null,"names":["crypto"],"mappings":";;;;;;;;;;;;;;;;;AAIA,2BAAA,kBAAA,CAAA,OAAA,KAAA,KAAA;AACA,EAAA,IAAA;AACA,IAAA,MAAA,IAAA,GAAA,MAAA,QAAA,CAAA,KAAA,CAAA;AACA,IAAA,MAAA,EAAA,OAAA,GAAA,IAAA;AAEA,IAAA,IAAA,CAAA,KAAA,EAAA;AACA,MAAA,MAAA,WAAA,CAAA;AAAA,QACA,UAAA,EAAA,GAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA,CAAA;AAAA,IACA;AAGA,IAAA,MAAA,KAAA,GAAA,MAAA,SAAA,EAAA;AACA,IAAA,MAAA,IAAA,GAAA,KAAA,CAAA,IAAA,CAAA,CAAA,CAAA,KAAA,CAAA,CAAA,MAAA,WAAA,EAAA,KAAA,KAAA,CAAA,WAAA,EAAA,CAAA;AAGA,IAAA,IAAA,CAAA,IAAA,EAAA;AACA,MAAA,OAAA;AAAA,QACA,OAAA,EAAA,IAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA;AAAA,IACA;AAGA,IAAA,MAAA,eAAAA,UAAA,CAAA,WAAA,CAAA,CAAA,CAAA,CAAA,SAAA,KAAA,CAAA;AACA,IAAA,MAAA,cAAA,GAAA,MAAA,YAAA,CAAA,YAAA,CAAA;AAGA,IAAA,IAAA,CAAA,QAAA,GAAA,cAAA;AACA,IAAA,IAAA,CAAA,qBAAA,GAAA,IAAA;AACA,IAAA,MAAA,YAAA,GAAA,MAAA,GAAA,CAAA,CAAA,CAAA,KAAA,EAAA,EAAA,KAAA,IAAA,CAAA,EAAA,GAAA,IAAA,GAAA,CAAA,CAAA;AACA,IAAA,MAAA,WAAA,YAAA,CAAA;AAGA,IAAA,MAAA,WAAA,GAAA,WAAA,eAAA,CAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,gBAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,GAAA;AAAA,MACA,MAAA,EAAA,KAAA;AAAA,MACA,IAAA,EAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA,SAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA;AAAA;AACA,KACA,CAAA;AAEA,IAAA,MAAA,WAAA,GAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,wBAAA;AAAA,MACA,IAAA,IAAA,CAAA,KAAA;AAAA,MACA,OAAA,EAAA,yCAAA;AAAA,MACA,IAAA,EAAA;AAAA;AAAA,iBAAA,EAEA,KAAA,IAAA,CAAA;AAAA;AAAA,sDAAA,EAEA,YAAA,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAAA;AAAA,KAOA;AAEA,IAAA,MAAA,WAAA,CAAA,SAAA,WAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA,SAAA,KAAA,EAAA;AACA,IAAA,OAAA,CAAA,KAAA,CAAA,0BAAA,KAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA;AACA,CAAA,CAAA;;;;"}
{"version":3,"file":"reset-password.post.mjs","sources":["../../../../../../server/api/auth/reset-password.post.js"],"sourcesContent":null,"names":[],"mappings":";;;;;;;;;;;;;;;;;AAIA,2BAAA,kBAAA,CAAA,OAAA,KAAA,KAAA;AACA,EAAA,IAAA;AACA,IAAA,MAAA,IAAA,GAAA,MAAA,QAAA,CAAA,KAAA,CAAA;AACA,IAAA,MAAA,EAAA,OAAA,GAAA,IAAA;AAEA,IAAA,IAAA,CAAA,KAAA,EAAA;AACA,MAAA,MAAA,WAAA,CAAA;AAAA,QACA,UAAA,EAAA,GAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA,CAAA;AAAA,IACA;AAGA,IAAA,MAAA,KAAA,GAAA,MAAA,SAAA,EAAA;AACA,IAAA,MAAA,IAAA,GAAA,KAAA,CAAA,IAAA,CAAA,CAAA,CAAA,KAAA,CAAA,CAAA,MAAA,WAAA,EAAA,KAAA,KAAA,CAAA,WAAA,EAAA,CAAA;AAGA,IAAA,IAAA,CAAA,IAAA,EAAA;AACA,MAAA,OAAA;AAAA,QACA,OAAA,EAAA,IAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA;AAAA,IACA;AAGA,IAAA,MAAA,eAAA,MAAA,CAAA,WAAA,CAAA,CAAA,CAAA,CAAA,SAAA,KAAA,CAAA;AACA,IAAA,MAAA,cAAA,GAAA,MAAA,YAAA,CAAA,YAAA,CAAA;AAGA,IAAA,IAAA,CAAA,QAAA,GAAA,cAAA;AACA,IAAA,IAAA,CAAA,qBAAA,GAAA,IAAA;AACA,IAAA,MAAA,YAAA,GAAA,MAAA,GAAA,CAAA,CAAA,CAAA,KAAA,EAAA,EAAA,KAAA,IAAA,CAAA,EAAA,GAAA,IAAA,GAAA,CAAA,CAAA;AACA,IAAA,MAAA,WAAA,YAAA,CAAA;AAGA,IAAA,MAAA,WAAA,GAAA,WAAA,eAAA,CAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,gBAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,GAAA;AAAA,MACA,MAAA,EAAA,KAAA;AAAA,MACA,IAAA,EAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA,SAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA;AAAA;AACA,KACA,CAAA;AAEA,IAAA,MAAA,WAAA,GAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,wBAAA;AAAA,MACA,IAAA,IAAA,CAAA,KAAA;AAAA,MACA,OAAA,EAAA,yCAAA;AAAA,MACA,IAAA,EAAA;AAAA;AAAA,iBAAA,EAEA,KAAA,IAAA,CAAA;AAAA;AAAA,sDAAA,EAEA,YAAA,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAAA;AAAA,KAOA;AAEA,IAAA,MAAA,WAAA,CAAA,SAAA,WAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA,SAAA,KAAA,EAAA;AACA,IAAA,OAAA,CAAA,KAAA,CAAA,0BAAA,KAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA;AACA,CAAA,CAAA;;;;"}

View File

@@ -1,4 +1,4 @@
import { d as defineEventHandler, g as getCookie, c as createError, b as getQuery } from '../../nitro/nitro.mjs';
import { d as defineEventHandler, g as getCookie, c as createError, f as getQuery } from '../../nitro/nitro.mjs';
import { b as verifyToken, e as getUserById } from '../../_/auth.mjs';
import { d as deleteNews } from '../../_/news.mjs';
import 'node:http';

View File

@@ -1,4 +1,4 @@
import { d as defineEventHandler, g as getCookie, c as createError, b as getQuery } from '../../nitro/nitro.mjs';
import { d as defineEventHandler, g as getCookie, c as createError, f as getQuery } from '../../nitro/nitro.mjs';
import { b as verifyToken, e as getUserById } from '../../_/auth.mjs';
import { d as deleteTermin } from '../../_/termine.mjs';
import 'node:http';

View File

@@ -1,5 +1,5 @@
import { createRenderer, getRequestDependencies, getPreloadLinks, getPrefetchLinks } from 'vue-bundle-renderer/runtime';
import { j as joinRelativeURL, u as useRuntimeConfig, e as getResponseStatusText, f as getResponseStatus, h as defineRenderHandler, b as getQuery, c as createError, i as destr, k as getRouteRules, l as relative, m as joinURL, n as useNitroApp } from '../nitro/nitro.mjs';
import { j as joinRelativeURL, u as useRuntimeConfig, h as getResponseStatusText, i as getResponseStatus, k as defineRenderHandler, f as getQuery, c as createError, l as destr, m as getRouteRules, n as relative, o as joinURL, p as useNitroApp } from '../nitro/nitro.mjs';
import { renderToString } from 'vue/server-renderer';
import { createHead as createHead$1, propsToString, renderSSRHead } from 'unhead/server';
import { stringify, uneval } from 'devalue';
@@ -272,7 +272,7 @@ async function renderInlineStyles(usedModules) {
const renderSSRHeadOptions = {"omitLineBreaks":true};
const entryFileName = "CmbXHhwn.js";
const entryFileName = "KrCelFbA.js";
globalThis.__buildAssetsURL = buildAssetsURL;
globalThis.__publicAssetsURL = publicAssetsURL;

View File

@@ -1,6 +1,6 @@
import process from 'node:process';globalThis._importMeta_={url:import.meta.url,env:process.env};import 'node:http';
import 'node:https';
export { F as default } from './chunks/nitro/nitro.mjs';
export { H as default } from './chunks/nitro/nitro.mjs';
import 'node:events';
import 'node:buffer';
import 'node:fs';

144
DATENSCHUTZ.md Normal file
View File

@@ -0,0 +1,144 @@
# Datenschutz und Verschlüsselung - Harheimer TC
## 🔒 **Verschlüsselung implementiert**
### **Verschlüsselte Daten:**
- **Mitgliedschaftsanträge:** Alle persönlichen Daten (Name, Adresse, E-Mail, Telefon, Bankdaten)
- **Benutzerdaten:** E-Mail, Name, Telefon (in `users.json`)
- **Mitgliederdaten:** Name, E-Mail, Telefon, Adresse (in `members.json`)
### **Nicht verschlüsselte Daten:**
- **News-Artikel:** Autor-Namen (öffentlich sichtbar)
- **Konfiguration:** Vereinsdaten, Trainingszeiten (öffentlich)
- **Termine:** Event-Daten (öffentlich)
## 🛡️ **Sicherheitsmaßnahmen**
### **Verschlüsselung:**
- **Algorithmus:** AES-256-GCM
- **Schlüsselableitung:** PBKDF2 mit 100.000 Iterationen
- **Salt:** 64 Bytes zufällig generiert
- **IV:** 16 Bytes zufällig generiert
- **Auth-Tag:** 16 Bytes für Integrität
### **Passwort-Sicherheit:**
- **Hashing:** bcryptjs mit Salt
- **Rounds:** 10 (konfigurierbar)
### **Datenzugriff:**
- **Authentifizierung:** JWT-Token
- **Autorisierung:** Rollenbasierte Zugriffskontrolle
- **Session-Management:** Sichere Cookies
## 📋 **Datenverarbeitung**
### **Mitgliedschaftsanträge:**
1. **Eingabe:** Formular auf Website
2. **Verschlüsselung:** Sofortige Verschlüsselung aller persönlichen Daten
3. **Speicherung:** Verschlüsselte Dateien in `server/data/membership-applications/`
4. **E-Mail:** Benachrichtigung an Vorstand/Trainer
5. **Genehmigung:** Entschlüsselung und Übertragung in Mitgliederliste
6. **Archivierung:** Original-Anträge bleiben verschlüsselt gespeichert
### **Benutzerdaten:**
- **Registrierung:** E-Mail, Name, Telefon
- **Login:** E-Mail, Passwort (gehashed)
- **Profil:** Bearbeitbare Kontaktdaten
- **Rollen:** admin, vorstand, mitglied
### **Mitgliederdaten:**
- **Manuell hinzugefügt:** Direkt in `members.json`
- **Aus Anträgen:** Nach Genehmigung übertragen
- **Zugriff:** Nur für "vorstand" Rolle sichtbar
## 🔄 **Datenfluss**
```
Mitgliedschaftsformular
Verschlüsselung (AES-256-GCM)
Speicherung (verschlüsselt)
E-Mail-Benachrichtigung
CMS-Verwaltung
Genehmigung/Ablehnung
Bei Genehmigung: Übertragung in Mitgliederliste
```
## ⚖️ **GDPR-Konformität**
### **Rechtmäßigkeit:**
- **Einwilligung:** Explizite Zustimmung im Formular
- **Vertragserfüllung:** Mitgliedschaftsvertrag
- **Berechtigtes Interesse:** Vereinsverwaltung
### **Betroffenenrechte:**
- **Auskunft:** Über gespeicherte Daten
- **Berichtigung:** Korrektur falscher Daten
- **Löschung:** Recht auf Vergessenwerden
- **Einschränkung:** Verarbeitung einschränken
- **Datenübertragbarkeit:** Export der Daten
### **Datenspeicherung:**
- **Zweckbindung:** Nur für Vereinszwecke
- **Minimierung:** Nur notwendige Daten
- **Genauigkeit:** Aktuelle und korrekte Daten
- **Speicherbegrenzung:** Löschung nach Zweckerfüllung
## 🔧 **Technische Implementierung**
### **Verschlüsselungsfunktionen:**
```javascript
// Verschlüsselung
encryptObject(data, password)
// Entschlüsselung
decryptObject(encryptedData, password)
// Passwort-Hashing
hashPassword(password, salt)
// Passwort-Verifikation
verifyPassword(password, hash, salt)
```
### **Environment-Variablen:**
```bash
# Verschlüsselungsschlüssel (MUSS in Produktion geändert werden!)
ENCRYPTION_KEY=your_secure_encryption_key_here
# JWT-Secret
JWT_SECRET=your_jwt_secret_here
```
## 🚨 **Sicherheitshinweise**
### **Produktionsumgebung:**
- **Verschlüsselungsschlüssel:** MUSS geändert werden!
- **JWT-Secret:** MUSS geändert werden!
- **HTTPS:** Zwingend erforderlich
- **Backup:** Verschlüsselte Backups
### **Entwicklungsumgebung:**
- **E-Mails:** Nur an `tsschulz@tsschulz.de`
- **Test-Daten:** Keine echten persönlichen Daten
- **Logs:** Keine sensiblen Daten in Logs
## 📞 **Kontakt**
Bei Fragen zum Datenschutz:
- **E-Mail:** tsschulz@tsschulz.de
- **Verantwortlicher:** Torsten Schulz
---
**Stand:** Januar 2025
**Version:** 1.0
**Letzte Aktualisierung:** Implementierung der Verschlüsselung

View File

@@ -3,7 +3,7 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0">
<p class="text-sm text-gray-400">
© {{ currentYear }} Harheimer TC
© {{ currentYear }} Harheimer TC 1954 e.V.
</p>
<div class="flex items-center space-x-6 text-sm relative">
<NuxtLink to="/impressum" class="text-gray-400 hover:text-primary-400 transition-colors">

View File

@@ -0,0 +1,149 @@
<template>
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Mitgliedschaft
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Werden Sie Teil unserer Tischtennis-Familie - Wählen Sie die passende Mitgliedschaft für sich
</p>
</div>
<div class="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
<div
v-for="plan in plans"
:key="plan.name"
:class="[
'relative bg-white rounded-2xl shadow-xl overflow-hidden',
plan.popular ? 'ring-4 ring-primary-500 scale-105' : ''
]"
>
<div v-if="plan.popular" class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg">
Beliebt
</div>
<div :class="['h-2 bg-gradient-to-r', plan.gradient]" />
<div class="p-8">
<div :class="['w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4', plan.gradient]">
<component :is="plan.icon" :size="24" class="text-white" />
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">
{{ plan.name }}
</h3>
<p class="text-gray-600 mb-6 min-h-[3rem]">
{{ plan.description }}
</p>
<div class="mb-6">
<div class="flex items-baseline">
<span class="text-5xl font-bold text-gray-900">{{ plan.price }}</span>
<span class="text-gray-600 ml-2">/ {{ plan.period }}</span>
</div>
</div>
<ul class="space-y-3 mb-8">
<li v-for="feature in plan.features" :key="feature" class="flex items-start">
<Check :size="20" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<span class="text-gray-700">{{ feature }}</span>
</li>
</ul>
<NuxtLink
to="/kontakt"
:class="[
'block w-full text-center px-6 py-3 rounded-lg font-semibold transition-all duration-300',
plan.popular
? 'bg-primary-600 hover:bg-primary-700 text-white shadow-lg hover:shadow-xl'
: 'bg-gray-100 hover:bg-gray-200 text-gray-900'
]"
>
Jetzt beitreten
</NuxtLink>
</div>
</div>
</div>
<!-- Satzung Download -->
<div class="mt-16 bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div class="text-center mb-8">
<h3 class="text-3xl font-display font-bold text-gray-900 mb-4">
Vereinsatzung
</h3>
<p class="text-xl text-gray-600">
Laden Sie unsere aktuelle Vereinsatzung herunter
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a
href="/documents/satzung.pdf"
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<FileText :size="20" class="mr-2" />
Satzung herunterladen (PDF)
</a>
<span class="text-sm text-gray-500">oder</span>
<NuxtLink
to="/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"
>
<Eye :size="20" class="mr-2" />
Online ansehen
</NuxtLink>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Check, Star, Heart, FileText, Eye, User, Users } from 'lucide-vue-next'
const config = ref(null)
const loadConfig = async () => {
try {
const response = await $fetch('/api/config')
config.value = response
} catch (error) {
console.error('Fehler beim Laden der Config:', error)
}
}
onMounted(() => {
loadConfig()
})
const plans = computed(() => {
if (!config.value?.mitgliedschaft) return []
const icons = [Star, Check, Heart, User, Users]
const gradients = [
'from-blue-500 to-cyan-500',
'from-primary-500 to-green-600',
'from-orange-500 to-red-500',
'from-purple-500 to-pink-500',
'from-indigo-500 to-blue-500'
]
return config.value.mitgliedschaft.map((m, index) => ({
name: m.typ,
description: m.beschreibung || '',
price: m.preis.toString(),
period: 'Jahr',
features: m.features || [],
icon: icons[index % icons.length],
gradient: gradients[index % gradients.length],
popular: index === 0
}))
})
</script>

View File

@@ -252,6 +252,11 @@
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors">
Einstellungen
</NuxtLink>
<NuxtLink to="/cms/mitgliedschaftsantraege"
@click="showCmsDropdown = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors">
Mitgliedschaftsanträge
</NuxtLink>
<NuxtLink to="/cms/benutzer"
@click="showCmsDropdown = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors">
@@ -464,6 +469,10 @@
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Einstellungen
</NuxtLink>
<NuxtLink to="/cms/mitgliedschaftsantraege" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Mitgliedschaftsanträge
</NuxtLink>
<NuxtLink to="/cms/benutzer" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Benutzerverwaltung

View File

@@ -1,16 +1,32 @@
# SMTP-Konfiguration für E-Mail-Versand
# Environment-Konfiguration für lokale Entwicklung
# Diese Datei sollte in .env umbenannt werden und nicht in Git committet werden
# SMTP-Server (z.B. Gmail, GMX, etc.)
# Node.js Environment
NODE_ENV=development
# Server-Konfiguration
PORT=3100
NUXT_PUBLIC_BASE_URL=http://localhost:3100
# SMTP-Konfiguration für E-Mail-Versand (nur für lokale Tests)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=j.dichmann@gmx.de
SMTP_PASS=your_email_password_here
SMTP_USER=tsschulz@tsschulz.de
SMTP_PASS=your_app_password_here
# Alternative für GMX:
# SMTP_HOST=mail.gmx.net
# SMTP_PORT=587
# Alternative für andere Provider:
# SMTP_HOST=smtp.your-provider.com
# SMTP_PORT=587
# JWT Secret für lokale Entwicklung
JWT_SECRET=local_development_secret_key_change_in_production
# Verschlüsselungsschlüssel für persönliche Daten (MUSS in Produktion geändert werden!)
ENCRYPTION_KEY=local_development_encryption_key_change_in_production
# Datenbank/Datei-Pfade
DATA_PATH=server/data
# Debug-Modi
DEBUG=true
VERBOSE_LOGGING=true

View File

@@ -6,7 +6,29 @@ export default defineNuxtConfig({
nitro: {
preset: 'node-server',
dev: false
dev: process.env.NODE_ENV !== 'production'
},
// Erzwinge Dev-Port und Host zuverlässig für `npm run dev`
devServer: {
port: 3100,
host: '0.0.0.0'
},
runtimeConfig: {
// Private keys (only available on server-side)
jwtSecret: process.env.JWT_SECRET || 'local_development_secret_key_change_in_production',
encryptionKey: process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production',
smtpHost: process.env.SMTP_HOST || 'smtp.gmail.com',
smtpPort: process.env.SMTP_PORT || 587,
smtpUser: process.env.SMTP_USER || 'tsschulz@tsschulz.de',
smtpPass: process.env.SMTP_PASS || '',
// Public keys (exposed to client-side)
public: {
baseUrl: process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100',
nodeEnv: process.env.NODE_ENV || 'development'
}
},
app: {
@@ -37,6 +59,54 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
compatibilityDate: '2024-04-03'
compatibilityDate: '2024-04-03',
hooks: {
'build:before': async () => {
const fs = await import('fs/promises')
const path = await import('path')
// Erstelle uploads Verzeichnis im public Ordner
const uploadsDir = path.join(process.cwd(), '.output', 'public', 'uploads')
try {
await fs.mkdir(uploadsDir, { recursive: true })
console.log('✅ Uploads-Verzeichnis erstellt:', uploadsDir)
} catch (error) {
console.log(' Uploads-Verzeichnis bereits vorhanden oder Fehler:', error.message)
}
// Erstelle temp Verzeichnis für LaTeX-Kompilierung
const tempDir = path.join(process.cwd(), '.output', 'temp')
try {
await fs.mkdir(tempDir, { recursive: true })
console.log('✅ Temp-Verzeichnis erstellt:', tempDir)
} catch (error) {
console.log(' Temp-Verzeichnis bereits vorhanden oder Fehler:', error.message)
}
},
'build:after': async () => {
const fs = await import('fs/promises')
const path = await import('path')
// Erstelle uploads Verzeichnis im public Ordner (nach dem Build)
const uploadsDir = path.join(process.cwd(), '.output', 'public', 'uploads')
try {
await fs.mkdir(uploadsDir, { recursive: true })
console.log('✅ Uploads-Verzeichnis nach Build erstellt:', uploadsDir)
} catch (error) {
console.log(' Uploads-Verzeichnis bereits vorhanden oder Fehler:', error.message)
}
// Erstelle temp Verzeichnis für LaTeX-Kompilierung (nach dem Build)
const tempDir = path.join(process.cwd(), '.output', 'temp')
try {
await fs.mkdir(tempDir, { recursive: true })
console.log('✅ Temp-Verzeichnis nach Build erstellt:', tempDir)
} catch (error) {
console.log(' Temp-Verzeichnis bereits vorhanden oder Fehler:', error.message)
}
}
}
})

43
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"multer": "^2.0.2",
"nodemailer": "^7.0.9",
"nuxt": "^4.1.3",
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pinia": "^3.0.3",
"vue": "^3.5.22"
@@ -2718,6 +2719,24 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pinia/nuxt": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
@@ -8254,6 +8273,12 @@
"integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
@@ -8375,6 +8400,24 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",

View File

@@ -19,6 +19,7 @@
"multer": "^2.0.2",
"nodemailer": "^7.0.9",
"nuxt": "^4.1.3",
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pinia": "^3.0.3",
"vue": "^3.5.22"

View File

@@ -0,0 +1,317 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Fixed Header -->
<div class="fixed top-16 left-0 right-0 bg-white shadow-sm border-b border-gray-200 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between py-3 sm:py-4">
<h1 class="text-xl sm:text-3xl font-bold text-gray-900">
Mitgliedschaftsanträge
</h1>
<button
@click="refreshApplications"
:disabled="loading"
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
>
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
</button>
</div>
</div>
</div>
<!-- Content -->
<div class="pt-20 sm:pt-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Lade Anträge...</p>
</div>
<!-- Empty State -->
<div v-else-if="applications.length === 0" class="text-center py-12">
<div class="text-gray-400 text-6xl mb-4">📋</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Anträge vorhanden</h3>
<p class="text-gray-600">Es wurden noch keine Mitgliedschaftsanträge eingereicht.</p>
</div>
<!-- Applications List -->
<div v-else class="space-y-6">
<div
v-for="application in applications"
:key="application.id"
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
>
<!-- Application Header -->
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
</h3>
<p class="text-sm text-gray-600">
Eingereicht: {{ formatDate(application.timestamp) }}
</p>
</div>
<div class="flex items-center space-x-3">
<!-- Status Badge -->
<span
:class="[
'px-3 py-1 rounded-full text-sm font-medium',
getStatusClass(application.status)
]"
>
{{ getStatusText(application.status) }}
</span>
<!-- Actions -->
<div class="flex space-x-2">
<button
@click="viewApplication(application)"
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
Anzeigen
</button>
<button
v-if="application.status === 'pending'"
@click="approveApplication(application.id)"
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
>
Genehmigen
</button>
<button
v-if="application.status === 'pending'"
@click="rejectApplication(application.id)"
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
<!-- Application Details -->
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
<div class="space-y-1 text-sm text-gray-600">
<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_mobil">
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
</p>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
<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>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Application Detail Modal -->
<div
v-if="selectedApplication"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
</h2>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600"
>
<svg 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"></path>
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Personal Data -->
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
<div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</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_mobil">
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
</p>
</div>
</div>
<!-- Application Details -->
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
<div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<button
@click="closeModal"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Schließen
</button>
<button
v-if="selectedApplication.status === 'pending'"
@click="approveApplication(selectedApplication.id)"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
Genehmigen
</button>
<button
v-if="selectedApplication.status === 'pending'"
@click="rejectApplication(selectedApplication.id)"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const applications = ref([])
const loading = ref(false)
const selectedApplication = ref(null)
const loadApplications = async () => {
loading.value = true
try {
const response = await $fetch('/api/membership/applications')
applications.value = response
} catch (error) {
console.error('Fehler beim Laden der Anträge:', error)
alert('Fehler beim Laden der Anträge')
} finally {
loading.value = false
}
}
const refreshApplications = () => {
loadApplications()
}
const viewApplication = (application) => {
selectedApplication.value = application
}
const closeModal = () => {
selectedApplication.value = null
}
const approveApplication = async (id) => {
if (confirm('Antrag genehmigen?')) {
try {
await $fetch('/api/membership/update-status', {
method: 'PUT',
body: { id, status: 'approved' }
})
await loadApplications()
alert('Antrag wurde genehmigt')
} catch (error) {
console.error('Fehler beim Genehmigen:', error)
alert('Fehler beim Genehmigen des Antrags')
}
}
}
const rejectApplication = async (id) => {
if (confirm('Antrag ablehnen?')) {
try {
await $fetch('/api/membership/update-status', {
method: 'PUT',
body: { id, status: 'rejected' }
})
await loadApplications()
alert('Antrag wurde abgelehnt')
} catch (error) {
console.error('Fehler beim Ablehnen:', error)
alert('Fehler beim Ablehnen des Antrags')
}
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getStatusClass = (status) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800'
case 'approved':
return 'bg-green-100 text-green-800'
case 'rejected':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getStatusText = (status) => {
switch (status) {
case 'pending':
return 'Ausstehend'
case 'approved':
return 'Genehmigt'
case 'rejected':
return 'Abgelehnt'
default:
return 'Unbekannt'
}
}
onMounted(() => {
loadApplications()
})
useHead({
title: 'Mitgliedschaftsanträge - CMS - Harheimer TC',
})
</script>

View File

@@ -1,14 +1,562 @@
<template>
<div class="min-h-screen">
<Membership />
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-4xl 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">
Mitgliedschaft
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<!-- Mitgliedschaftspläne (ohne "Noch Fragen" Box) -->
<div class="mb-12">
<MembershipNoQuestions />
</div>
<!-- Aufnahmeantrag Formular -->
<div class="bg-white rounded-xl shadow-lg p-8">
<h2 class="text-3xl font-display font-bold text-gray-900 mb-6">
Beitrittserklärung
</h2>
<form @submit.prevent="submitForm" class="space-y-8">
<!-- Persönliche Daten -->
<div class="space-y-6">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
Persönliche Daten
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="nachname" class="block text-sm font-medium text-gray-700 mb-2">
Nachname
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="nachname"
v-model="form.nachname"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="vorname" class="block text-sm font-medium text-gray-700 mb-2">
Vorname
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="vorname"
v-model="form.vorname"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label for="strasse" class="block text-sm font-medium text-gray-700 mb-2">
Straße und Hausnummer
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="strasse"
v-model="form.strasse"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="plz" class="block text-sm font-medium text-gray-700 mb-2">
PLZ
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="plz"
v-model="form.plz"
type="text"
required
pattern="[0-9]{5}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="ort" class="block text-sm font-medium text-gray-700 mb-2">
Wohnort
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="ort"
v-model="form.ort"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="geburtsdatum" class="block text-sm font-medium text-gray-700 mb-2">
Geburtsdatum
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="geburtsdatum"
v-model="form.geburtsdatum"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="telefon_privat" class="block text-sm font-medium text-gray-700 mb-2">
Telefon (privat)
</label>
<input
id="telefon_privat"
v-model="form.telefon_privat"
type="tel"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="telefon_mobil" class="block text-sm font-medium text-gray-700 mb-2">
Telefon (Mobil)
</label>
<input
id="telefon_mobil"
v-model="form.telefon_mobil"
type="tel"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</div>
<!-- Mitgliedschaftsart -->
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
Mitgliedschaftsart
</h3>
<div class="space-y-3">
<label class="flex items-center">
<input
v-model="form.mitgliedschaftsart"
type="radio"
value="aktiv"
class="mr-3 text-primary-600 focus:ring-primary-500"
/>
<span class="text-gray-700">Aktives Mitglied</span>
</label>
<label class="flex items-center">
<input
v-model="form.mitgliedschaftsart"
type="radio"
value="passiv"
class="mr-3 text-primary-600 focus:ring-primary-500"
/>
<span class="text-gray-700">Passives Mitglied</span>
</label>
</div>
</div>
<!-- Beitragszahlung -->
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
Beitragszahlung
</h3>
<div class="bg-gray-50 p-4 rounded-lg">
<p class="text-gray-700 mb-4">
Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von:
</p>
<ul class="space-y-2 text-gray-700">
<li> 120,-- (Erwachsene)</li>
<li> 72,-- (Jugendliche bis zum vollendeten 18. Lebensjahr)</li>
<li> 30,-- (passive Mitglieder)</li>
</ul>
<p class="text-gray-700 mt-4">
bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.
</p>
</div>
<label class="flex items-start">
<input
v-model="form.lastschrift_erlaubt"
type="checkbox"
required
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
/>
<div>
<span class="text-gray-700">
Hierzu erteile ich das beigefügte SEPA-Lastschriftmandat.
</span>
<p class="text-xs text-gray-500 italic mt-1">Pflichtfeld</p>
</div>
</label>
</div>
<!-- Bankdaten -->
<div class="space-y-6">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
Bankdaten für SEPA-Lastschrift
</h3>
<div>
<label for="kontoinhaber" class="block text-sm font-medium text-gray-700 mb-2">
Kontoinhaber (Vorname und Name)
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="kontoinhaber"
v-model="form.kontoinhaber"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="iban" class="block text-sm font-medium text-gray-700 mb-2">
IBAN
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<input
id="iban"
v-model="form.iban"
type="text"
required
placeholder="DE89 3704 0044 0532 0130 00"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="bic" class="block text-sm font-medium text-gray-700 mb-2">
BIC
</label>
<input
id="bic"
v-model="form.bic"
type="text"
placeholder="COBADEFFXXX"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label for="bank" class="block text-sm font-medium text-gray-700 mb-2">
Kreditinstitut
</label>
<input
id="bank"
v-model="form.bank"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<!-- Datenschutz -->
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
Datenschutz und Einverständniserklärung
</h3>
<div class="bg-blue-50 p-4 rounded-lg">
<p class="text-sm text-gray-700 mb-4">
Der Vereinsvorstand weist darauf hin, dass ausreichende technische Maßnahmen zur Gewährleistung des Datenschutzes getroffen wurden. Dennoch kann bei einer Veröffentlichung von personenbezogenen Mitgliederdaten im Internet ein umfassender Datenschutz nicht garantiert werden.
</p>
<label class="flex items-start">
<input
v-model="form.datenschutz_einverstanden"
type="checkbox"
required
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
/>
<div>
<span class="text-sm text-gray-700">
Ich bestätige das Vorstehende zur Kenntnis genommen zu haben und willige ein, dass der Harheimer Tischtennis-Club 1954 e.V. allgemeine Daten zu meiner Person (Name, Fotografien, Mannschaft, Leistungsergebnisse, Turnierteilnahmen, Lizenzen u.ä.) auf der Homepage des Vereins veröffentlichen darf.
</span>
<p class="text-xs text-gray-500 italic mt-1">Pflichtfeld</p>
</div>
</label>
</div>
</div>
<!-- Vereinssatzung -->
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
Vereinssatzung
</h3>
<label class="flex items-start">
<input
v-model="form.satzung_anerkannt"
type="checkbox"
required
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
/>
<div>
<span class="text-gray-700">
Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.
</span>
<p class="text-xs text-gray-500 italic mt-1">Pflichtfeld</p>
</div>
</label>
</div>
<!-- Hinweise -->
<div class="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<h4 class="font-semibold text-gray-900 mb-2">Wichtige Hinweise:</h4>
<ul class="text-sm text-gray-700 space-y-1">
<li> Die Mitgliedschaft im Harheimer Tischtennis-Club erlangt erst nach Bestätigung durch den Vorstand Wirksamkeit.</li>
<li> Die Beitragspflicht beginnt mit dem darauf folgenden Monat.</li>
<li> Sie können Ihre Einwilligung zur Datenveröffentlichung jederzeit widerrufen.</li>
</ul>
</div>
<!-- Submit Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center pt-6">
<button
type="button"
@click="fillWithDummyData"
:disabled="isGenerating"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<svg class="mr-2 h-5 w-5" 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"></path>
</svg>
Mit Testdaten füllen
</button>
<button
type="submit"
:disabled="!isFormValid || isGenerating"
class="px-8 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<svg v-if="isGenerating" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isGenerating ? 'Formular wird erstellt...' : 'Beitrittsformular erstellen' }}
</button>
</div>
</form>
</div>
<!-- Noch Fragen Box -->
<div class="mt-12 bg-gradient-to-r from-primary-600 to-primary-700 rounded-2xl p-8 sm:p-12 text-center">
<h3 class="text-3xl font-display font-bold text-white mb-4">
Noch Fragen zur Mitgliedschaft?
</h3>
<p class="text-xl text-primary-100 mb-6">
Kontaktieren Sie uns - wir beraten Sie gerne persönlich
</p>
<NuxtLink
to="/kontakt"
class="inline-flex items-center px-8 py-4 bg-white text-primary-600 font-semibold rounded-lg hover:bg-gray-100 transition-colors"
>
Jetzt Kontakt aufnehmen
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
import Membership from '~/components/Membership.vue'
import { ref, computed } from 'vue'
const form = ref({
// Persönliche Daten
nachname: '',
vorname: '',
strasse: '',
plz: '',
ort: '',
geburtsdatum: '',
telefon_privat: '',
email: '',
telefon_mobil: '',
// Mitgliedschaftsart
mitgliedschaftsart: 'aktiv',
// Bankdaten
kontoinhaber: '',
iban: '',
bic: '',
bank: '',
// Einverständnisse
lastschrift_erlaubt: false,
datenschutz_einverstanden: false,
satzung_anerkannt: false
})
const isGenerating = ref(false)
const isFormValid = computed(() => {
return form.value.nachname &&
form.value.vorname &&
form.value.strasse &&
form.value.plz &&
form.value.ort &&
form.value.geburtsdatum &&
form.value.email &&
form.value.mitgliedschaftsart &&
form.value.kontoinhaber &&
form.value.iban &&
form.value.lastschrift_erlaubt &&
form.value.datenschutz_einverstanden &&
form.value.satzung_anerkannt
})
const isVolljaehrig = computed(() => {
if (!form.value.geburtsdatum) return true // Default zu volljährig
const heute = new Date()
const geburtsdatum = new Date(form.value.geburtsdatum)
const alter = heute.getFullYear() - geburtsdatum.getFullYear()
const monatDiff = heute.getMonth() - geburtsdatum.getMonth()
if (monatDiff < 0 || (monatDiff === 0 && heute.getDate() < geburtsdatum.getDate())) {
return alter - 1 >= 18
}
return alter >= 18
})
const fillWithDummyData = () => {
// Dummy-Daten für Testzwecke
form.value = {
// Persönliche Daten
nachname: 'Mustermann',
vorname: 'Max',
strasse: 'Musterstraße 123',
plz: '60437',
ort: 'Frankfurt am Main',
geburtsdatum: '1990-05-15', // Volljährig
telefon_privat: '069 12345678',
email: 'max.mustermann@example.com',
telefon_mobil: '0171 1234567',
// Mitgliedschaftsart
mitgliedschaftsart: 'aktiv',
// Bankdaten
kontoinhaber: 'Max Mustermann',
iban: 'DE89 3704 0044 0532 0130 00',
bic: 'COBADEFFXXX',
bank: 'Commerzbank AG',
// Einverständnisse
lastschrift_erlaubt: true,
datenschutz_einverstanden: true,
satzung_anerkannt: true
}
}
const submitForm = async () => {
if (!isFormValid.value) return
isGenerating.value = true
try {
const response = await $fetch('/api/membership/generate-pdf', {
method: 'POST',
body: {
...form.value,
isVolljaehrig: isVolljaehrig.value
}
})
if (response.success) {
// PDF herunterladen über geschützten Endpoint
try {
const downloadResponse = await $fetch(response.downloadUrl, {
method: 'GET',
responseType: 'blob'
})
// Blob zu Download-Link konvertieren
const blob = new Blob([downloadResponse], {
type: 'application/pdf'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `Beitrittserklärung_${form.value.nachname}_${form.value.vorname}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
// Erfolgsmeldung
alert('Beitrittsformular wurde erfolgreich erstellt und per E-Mail an den Vorstand/Trainer gesendet!')
} catch (downloadError) {
console.error('Download-Fehler:', downloadError)
alert('Formular wurde erfolgreich erstellt und per E-Mail an den Vorstand/Trainer gesendet!\n\nFalls der Download fehlschlägt, können Sie das Formular auch später über den Link in der E-Mail herunterladen.')
}
// Formular zurücksetzen
form.value = {
nachname: '',
vorname: '',
strasse: '',
plz: '',
ort: '',
geburtsdatum: '',
telefon_privat: '',
email: '',
telefon_mobil: '',
mitgliedschaftsart: 'aktiv',
kontoinhaber: '',
iban: '',
bic: '',
bank: '',
lastschrift_erlaubt: false,
datenschutz_einverstanden: false,
satzung_anerkannt: false
}
} else {
alert('Fehler beim Erstellen des Formulars: ' + (response.error || 'Unbekannter Fehler'))
}
} catch (error) {
console.error('Fehler:', error)
alert('Fehler beim Erstellen des Formulars. Bitte versuchen Sie es erneut.')
} finally {
isGenerating.value = false
}
}
useHead({
title: 'Mitgliedschaft - Harheimer TC',
})
</script>
</script>

View File

@@ -0,0 +1 @@
gNCsLBXSvmB3axKuSGH1YPk8nnWkAXzK8gu81Xfc2MvGz4iZ4IBhCtl+geidl/ZkdZC+qYXgb3BnrY4dmRdVb++IJU3TpLdkRthdkVKuTZOwXE/YxqlNApFT+bWUw21V0riC1Clkx0zY4kW33zZHCDk+rpPfjW+fk8jRp9uvKFChu9SlT5DMriO/s3R0IU/fU5YN9DG1FCnZT1LkpH6Lr7FhbqHAo6VFpF6Xo4KkuyS+WMIvdxS/mf3tyx1Th1Wc3kE/ljHeJviRBOXTQlOK4DkJZfir4JAhPdgXzYZan2z9WDCVq5DjzsAGZ1x7FSJHbM62Fg3NWlrnnY+FtTkHaBHRfTa7tVSeCE/re0b03HOQtwzt12gxt9/LwYXiKidFaRpceXP7oJVurOJaW/KnquAJMRs9XaY6EZQ0G3+HrDy6yx4/uj9lnlhIAGAvWEAKrVDLk2HWc6B7ud7lbU3J+a8AYRMz2rvdkdXUs0NgPJ7CfyCoQTIcvK9VVL5h3+8o/54L6qQEAV2rmcCqt32/0XXB3+ZlkpXZFvqWBn3hAV7WhKou3R8wdxBZimPZn7gYb0R4IsN1+cYCblz3pzgehss0hIewHRy+nnzE/aw5zNeE/m2ia0YAJFGtt31F9JqmQc2kvtcQWn4MmB1rJYbV5Htw79ihCCU38ZfIWIThjea8Sbx1DzxKJyr7NlAx7XzlUs4m66QUQOd3O7GP2d3L6Q==

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,357 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import fs from 'fs'
async function create() {
const pdfDoc = await PDFDocument.create()
const page = pdfDoc.addPage([595.28, 841.89]) // A4
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica)
const helvBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
const { width, height } = page.getSize()
// left column moved further left to align with checkboxes
const leftX = 48
const rightX = 320
// baseY is the vertical anchor for page-1 content. Increase it by ~5.2mm (≈14.739pt)
const baseY = height - 160 + 14.739
// shift to apply only to labels and input fields (move those 1cm down)
const labelsShift = -28.35
const gap = 36
const labelWidth = 100
const fieldXOffset = 100
// Make fields 10% narrower
const fieldWidth = Math.round(180 * 0.9)
// shrink most fields by another 20% on request (applied selectively later)
const fieldShrinkFactor = 0.8
const fieldHeight = 14
// raise date fields by 0.4cm (≈11.34pt) when requested; add extra 1.2mm (≈3.4016pt) per user request
const dateRaise = 11.34 + 3.4016 // now ≈14.7416pt
// fixed checkbox positions (do not move with leftX)
const cbX = 48
const cbYOffset = 5
// Header: centered club name and a full-width horizontal bar underneath (~2mm high)
const headerText = 'Harheimer Tischtennis-Club 1954 e.V.'
const headerSize = 20
const textWidth = helv.widthOfTextAtSize(headerText, headerSize)
const headerX = (width - textWidth) / 2
const headerY = height - 72
page.drawText(headerText, { x: headerX, y: headerY, size: headerSize, font: helv })
// draw full-width bar directly under the header; 2mm ≈ 5.67 points
const barHeight = 5.67
const barY = headerY - 20
page.drawRectangle({ x: 0, y: barY, width: width, height: barHeight, color: rgb(0,0,0) })
// Labels and lines
// Labels left-aligned in their columns
// Add form title above the fields
const titleText = 'Beitrittserklärung'
const titleSize = 14
const titleWidth = helvBold.widthOfTextAtSize(titleText, titleSize)
// left-align title above the left labels and move up ~0.7cm (≈20pt)
const titleX = leftX
// move title down by 0.5cm (≈14.17pt)
const titleY = baseY + 24 + 20 - 14.17
page.drawText(titleText, { x: titleX, y: titleY, size: titleSize, font: helvBold })
const subtitle = 'Hiermit beantrage ich,'
const subtitleSize = 12
const subtitleX = leftX
const subtitleY = titleY - 18 - 14.17
page.drawText(subtitle, { x: subtitleX, y: subtitleY, size: subtitleSize, font: helv })
// apply same vertical shift as fields so labels align
const fieldsShift = -14.17
page.drawText('Name:', { x: leftX, y: baseY + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Vorname:', { x: rightX, y: baseY + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Straße:', { x: leftX, y: baseY - gap + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('PLZ/Wohnort:', { x: rightX, y: baseY - gap + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Geburtsdatum:', { x: leftX, y: baseY - gap * 2 + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Telefon(privat):', { x: rightX, y: baseY - gap * 2 + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('E-Mail:', { x: leftX, y: baseY - gap * 3 + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Telefon(Mobil):', { x: rightX, y: baseY - gap * 3 + fieldsShift + labelsShift, size: 12, font: helv })
// Create form fields
const form = pdfDoc.getForm()
// Place fields on the same baseline as their labels
// We need to move only the input fields on page 1 up by 0.6cm (≈17.01pt) without moving labels.
const labelToFieldYDelta = 2 // small vertical offset so field baseline matches label visually
const lift = 0 // original lift value
// previously raised inputs by 17.01pt (0.6cm); move them down by 5.67pt (0.2cm)
const inputFieldRaise = 11.34 // net upward offset now ~11.34pt
form.createTextField('nachname').addToPage(page, { x: leftX + fieldXOffset, y: baseY - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('vorname').addToPage(page, { x: rightX + fieldXOffset, y: baseY - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('strasse').addToPage(page, { x: leftX + fieldXOffset, y: baseY - gap - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('plz_ort').addToPage(page, { x: rightX + fieldXOffset, y: baseY - gap - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('geburtsdatum').addToPage(page, { x: leftX + fieldXOffset, y: baseY - gap * 2 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('telefon').addToPage(page, { x: rightX + fieldXOffset, y: baseY - gap * 2 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('email').addToPage(page, { x: leftX + fieldXOffset, y: baseY - gap * 3 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('telefon_mobil').addToPage(page, { x: rightX + fieldXOffset, y: baseY - gap * 3 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
// read membership amounts from config (fall back to defaults)
let erw = 120, jug = 72, passv = 30
try {
const cfg = JSON.parse(fs.readFileSync('server/data/config.json', 'utf8'))
const ms = cfg.mitgliedschaft || []
erw = (ms.find(m => /Erwachsene/i.test(m.typ)) || ms.find(m => m.typ === 'Erwachsene') || {}).preis || erw
jug = (ms.find(m => /Jugend|Kinder/i.test(m.typ)) || ms.find(m => m.typ === 'Kinder/Jugend') || {}).preis || jug
passv = (ms.find(m => /Passiv/i.test(m.typ)) || {}).preis || passv
} catch (e) {
console.error('Could not read config for membership amounts', e)
}
const paraLines = [
'Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von',
`${erw},-- (Erwachsene)`,
`${jug},-- (Jugendliche bis zum vollendeten 18. Lebensjahr)`,
`${passv},-- (passive Mitglieder)`,
'bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.',
'Hierzu erteile ich beigefügtes SEPA-Lastschriftmandat.',
'Mir ist bekannt, dass die Mitgliedschaft im Harheimer Tischtennis-Club erst nach Bestätigung durch den',
'Vorstand Wirksamkeit erlangt. Die Beitragspflicht beginnt mit dem darauf folgenden Monat.',
'Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.'
]
// compute intermediate line position and move it down by additional 0.3cm ≈ 8.5pt
const intermediateY = baseY - gap * 3 + fieldsShift - 6 - 14.17 - 8.5 - 8.5 + labelsShift
page.drawText('meine Aufnahme in den Harheimer Tischtennis-Club 1954 e.V. als', { x: leftX, y: intermediateY, size: 12, font: helv })
// membership checkboxes
// Keep checkbox labels and boxes at fixed left positions, but move them down by 0.8cm ≈ 22.68pt
const cbLift = -22.68
page.drawText('aktives Mitglied', { x: cbX + 16, y: baseY - gap * 5 + cbLift + labelsShift, size: 12, font: helv })
page.drawText('passives Mitglied', { x: cbX + 16, y: baseY - gap * 6 + cbLift + labelsShift, size: 12, font: helv })
form.createCheckBox('mitglied_aktiv').addToPage(page, { x: cbX, y: baseY - gap * 5 - cbYOffset + cbLift + labelsShift, width: 12, height: 12 })
form.createCheckBox('mitglied_passiv').addToPage(page, { x: cbX, y: baseY - gap * 6 - cbYOffset + cbLift + labelsShift, width: 12, height: 12 })
// place the paragraph below the checkboxes with extra spacing before and after the cost lines (~0.3cm)
try {
// increase gap around cost lines for better readability (~0.6cm)
const beforeCostGap = 17
const afterCostGap = 17
const paraStartY = baseY - gap * 6 + cbLift - 28 - beforeCostGap
const lineHeight = 14
// insert explicit gaps before and after the cost lines (lines 1-3)
let y = paraStartY
// first line
page.drawText(paraLines[0], { x: leftX, y: y, size: 11, font: helv })
y -= lineHeight
// gap before costs
y -= beforeCostGap
// cost lines (1..3)
for (let k = 1; k <= 3; k++) {
page.drawText(paraLines[k], { x: leftX + 6, y: y, size: 11, font: helv })
y -= lineHeight
}
// gap after costs
y -= afterCostGap
// remaining lines
for (let i = 4; i < paraLines.length; i++) {
page.drawText(paraLines[i], { x: leftX, y: y, size: 11, font: helv })
y -= lineHeight
}
// shift following content down by afterCostGap if needed (none after currently)
// now add signature/city line 2 cm below the current text
const twoCm = 56.7
const signY = y - twoCm
const sigText = 'Frankfurt/Main-Harheim, den'
const sigSize = 12
page.drawText(sigText + ' ', { x: leftX, y: signY, size: sigSize, font: helv })
const sigStartX = leftX + helv.widthOfTextAtSize(sigText + ' ', sigSize)
const sigWidth = 220
// draw thin line for signature
page.drawRectangle({ x: sigStartX, y: signY - 2, width: sigWidth, height: 1, color: rgb(0,0,0) })
// draw 'Datum' centered under the signature line
const datum = 'Datum'
const datumSize = 11
const datumX = sigStartX + sigWidth / 2 - helv.widthOfTextAtSize(datum, datumSize) / 2
page.drawText(datum, { x: datumX, y: signY - 18, size: datumSize, font: helv })
// create a date form field on page 1 centered under the date line
const dateFieldWidth = 120
const dateFieldX = sigStartX + sigWidth / 2 - dateFieldWidth / 2
// position date field so its bottom edge is exactly on the signature line
const signatureLineY = signY - 2 // the 1pt-high rectangle was drawn at signY-2
// raise date fields by 0.4cm (≈11.34pt) upward relative to the line
const dateRaise = 11.34
// For page 1 we need to move only the date input up by 5.2mm (≈14.739pt)
const signDatumExtraRaise = 14.739
form.createTextField('sign_datum').addToPage(page, { x: dateFieldX, y: signatureLineY - fieldHeight + signDatumExtraRaise, width: dateFieldWidth, height: fieldHeight, font: helv })
// second signature line 3cm below first
const threeCm = 85.04
const secondY = signY - threeCm
const line2Width = 300
page.drawRectangle({ x: leftX, y: secondY - 2, width: line2Width, height: 1, color: rgb(0,0,0) })
const label2 = 'Unterschrift (bei Jugendlichen gesetzlicher Vertreter)'
page.drawText(label2, { x: leftX, y: secondY - 18, size: 11, font: helv })
} catch (e) {
// ignore
}
// footer: right-aligned 'Seite 1 von 3' 2cm from bottom
const footerY = 56.7
const footerText = 'Seite 1 von 3'
const footerSize = 10
const footerWidth = helv.widthOfTextAtSize(footerText, footerSize)
page.drawText(footerText, { x: width - footerWidth - leftX, y: footerY, size: footerSize, font: helv })
// --- Add a second page with the same header and horizontal bar ---
const page2 = pdfDoc.addPage([595.28, 841.89])
const { width: width2, height: height2 } = page2.getSize()
const textWidth2 = helv.widthOfTextAtSize(headerText, headerSize)
const headerX2 = (width2 - textWidth2) / 2
const headerY2 = height2 - 72
page2.drawText(headerText, { x: headerX2, y: headerY2, size: headerSize, font: helv })
const barY2 = headerY2 - 20
page2.drawRectangle({ x: 0, y: barY2, width: width2, height: barHeight, color: rgb(0,0,0) })
// --- Page 2: SEPA-Lastschriftmandat text and fields ---
// move SEPA section slightly up so title sits closer to the header bar
const sepaYStart = headerY2 - 54
const sepaLeft = leftX
// increase line gap for SEPA section (~22pt)
const lineGap = 22
let sy = sepaYStart
const small = 11
page2.drawText('Erteilung eines SEPA-Lastschriftmandates', { x: sepaLeft, y: sy, size: 12, font: helvBold })
sy -= lineGap
// draw these two lines as a tight block (no extra gap)
page2.drawText('Harheimer Tischtennis-Club 1954 e.V.', { x: sepaLeft, y: sy, size: small, font: helv })
sy -= 12
page2.drawText('Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362', { x: sepaLeft, y: sy, size: small, font: helv })
// add 0.7cm vertical gap (≈19.8pt) before the authorization paragraph
sy -= 19.8
// draw the authorization text as wrapped lines within page margins
const wrapMaxWidth = width2 - sepaLeft - 48
function drawWrapped(text, x, y, size, font) {
const words = text.split(' ')
let line = ''
let curY = y
for (const w of words) {
const test = line ? line + ' ' + w : w
const testWidth = helv.widthOfTextAtSize(test, size)
if (testWidth > wrapMaxWidth) {
page2.drawText(line, { x, y: curY, size, font })
line = w
curY -= size + 4
} else {
line = test
}
}
if (line) {
page2.drawText(line, { x, y: curY, size, font })
curY -= size + 4
}
return curY
}
sy = drawWrapped('Hiermit ermächtige ich den Harheimer Tischtennis-Club 1954 e.V. die jährlichen Mitgliedsbeiträge von meinem untenstehenden Konto per Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die vom Harheimer Tischtennis-Club 1954 e.V. auf mein Konto gezogenen Lastschriften einzulösen.', sepaLeft, sy, small, helv)
sy -= lineGap * 0.2
// draw mandate reference and validity with a small gap
page2.drawText('Mandatsreferenz: HTC0000 _ _ _', { x: sepaLeft, y: sy, size: small, font: helv })
// add 0.7cm space after Mandatsreferenz as requested
sy -= 19.8
page2.drawText('Dieses Mandat gilt für die zugrundeliegende Beitrittserklärung ab sofort.', { x: sepaLeft, y: sy, size: small, font: helv })
sy -= lineGap * 0.6
// Draw labeled lines and create text fields for mandate details
const fieldHeight2 = 14
const fieldWidth2 = 380
const labelOffset = 0
// place SEPA inputs 1cm left (≈28.35pt) from previous and slightly up; adjust to align with labels
const inputX = sepaLeft + 220 - 28.35
// reduce vertical raise so inputs sit on same baseline as labels (previously too high)
const inputYAdjust = 6
// apply labelsShift only to the SEPA form labels/inputs so paragraphs remain unaffected
let syFields = sy + labelsShift
page2.drawText('Mitglied (Vorname und Name)', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_mitglied').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('Kontoinhaber (Vorname und Name):', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_kontoinhaber').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('Straße und Hausnummer:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_strasse').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('PLZ und Ort:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_plz_ort').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('Kreditinstitut:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_bank').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('IBAN:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_iban').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('BIC:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
// BIC remains full width as requested
form.createTextField('sepa_bic').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: 220, height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
// add signature and date lines 2cm below last field
const twoCm = 56.7
const signY2 = syFields - twoCm
// date text and line
const sigDateText = 'Frankfurt/Main-Harheim, den'
page2.drawText(sigDateText + ' ', { x: sepaLeft, y: signY2, size: 12, font: helv })
const sigDateStartX = sepaLeft + helv.widthOfTextAtSize(sigDateText + ' ', 12)
const sigDateWidth = 160
page2.drawRectangle({ x: sigDateStartX, y: signY2 - 2, width: sigDateWidth, height: 1, color: rgb(0,0,0) })
page2.drawText('Datum', { x: sigDateStartX + sigDateWidth / 2 - helv.widthOfTextAtSize('Datum', 11) / 2, y: signY2 - 18, size: 11, font: helv })
// create a date form field on page 2 centered under the date line
const sepaDateFieldWidth = 120
const sepaDateFieldX = sigDateStartX + sigDateWidth / 2 - sepaDateFieldWidth / 2
// position sepa date field so its bottom edge is on the signature line
// raise SEPA date field by the same amount so its top/bottom alignment matches requested position
form.createTextField('sepa_datum').addToPage(page2, { x: sepaDateFieldX, y: signY2 - 2 - fieldHeight2 + dateRaise, width: sepaDateFieldWidth, height: fieldHeight2, font: helv })
// footer on page 2: right-aligned 'Seite 2 von 3' 2cm from bottom
const footerText2 = 'Seite 2 von 3'
const footerSize2 = 10
const footerWidth2 = helv.widthOfTextAtSize(footerText2, footerSize2)
page2.drawText(footerText2, { x: width2 - footerWidth2 - leftX, y: 56.7, size: footerSize2, font: helv })
// signature line
// move signature label/line 2cm (≈56.7pt) further down
const signLineY = signY2 - 36 - 56.7
const signLineWidth = 300
page2.drawRectangle({ x: sepaLeft, y: signLineY - 2, width: signLineWidth, height: 1, color: rgb(0,0,0) })
page2.drawText('Unterschrift des Kontoinhabers', { x: sepaLeft, y: signLineY - 18, size: 11, font: helv })
// no form field for signature on page 2 (physical signature)
const pdfBytes = await pdfDoc.save()
fs.writeFileSync('server/templates/mitgliedschaft-fillable.pdf', pdfBytes)
console.log('Wrote server/templates/mitgliedschaft-fillable.pdf')
// --- Add a third page with same header/bar/footer and title 'Einwilligungserklärung' ---
const page3 = pdfDoc.addPage([595.28, 841.89])
const { width: width3, height: height3 } = page3.getSize()
const headerX3 = (width3 - textWidth) / 2
const headerY3 = height3 - 72
page3.drawText(headerText, { x: headerX3, y: headerY3, size: headerSize, font: helv })
const barY3 = headerY3 - 20
page3.drawRectangle({ x: 0, y: barY3, width: width3, height: barHeight, color: rgb(0,0,0) })
// title for page 3
const page3Title = 'Einwilligungserklärung'
const page3TitleSize = 14
const page3TitleX = leftX
const page3TitleY = headerY3 - 48
page3.drawText(page3Title, { x: page3TitleX, y: page3TitleY, size: page3TitleSize, font: helvBold })
// footer on page 3
const footerText3 = 'Seite 3 von 3'
const footerSize3 = 10
const footerWidth3 = helv.widthOfTextAtSize(footerText3, footerSize3)
page3.drawText(footerText3, { x: width3 - footerWidth3 - leftX, y: 56.7, size: footerSize3, font: helv })
}
create().catch(e => {
console.error(e)
process.exit(1)
})

18
scripts/fetch-template.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
# Lädt das offizielle Aufnahmeantrag-PDF in server/templates/
# Usage: ./scripts/fetch-template.sh [URL]
# Default URL: https://harheimertc.de/Aufnahmeantrag%202025.pdf
TEMPLATE_URL=${1:-"https://harheimertc.de/Aufnahmeantrag%202025.pdf"}
TEMPLATES_DIR="$(pwd)/server/templates"
mkdir -p "$TEMPLATES_DIR"
echo "Lade Template von: $TEMPLATE_URL"
curl -fL "$TEMPLATE_URL" -o "$TEMPLATES_DIR/Aufnahmeantrag 2025.pdf"
echo "Template gespeichert als: $TEMPLATES_DIR/Aufnahmeantrag 2025.pdf"
chmod 644 "$TEMPLATES_DIR/Aufnahmeantrag 2025.pdf"
echo "Fertig."

View File

@@ -0,0 +1,72 @@
import fs from 'fs/promises'
import path from 'path'
import { decryptObject } from '../../utils/encryption.js'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const encryptionKey = config.encryptionKey
if (!encryptionKey) {
throw createError({
statusCode: 500,
statusMessage: 'Verschlüsselungsschlüssel nicht konfiguriert'
})
}
const dataDir = path.join(process.cwd(), 'server/data/membership-applications')
// Prüfen ob Verzeichnis existiert
try {
await fs.access(dataDir)
} catch {
return []
}
// Alle Anträge laden
const files = await fs.readdir(dataDir)
const applications = []
for (const file of files) {
if (file.endsWith('.json')) {
try {
const filePath = path.join(dataDir, file)
const fileContent = await fs.readFile(filePath, 'utf8')
const applicationData = JSON.parse(fileContent)
// Verschlüsselte Daten entschlüsseln
const decryptedData = decryptObject(applicationData.encryptedData, encryptionKey)
applications.push({
id: applicationData.id,
timestamp: applicationData.timestamp,
status: applicationData.status,
metadata: applicationData.metadata,
// Entschlüsselte persönliche Daten
personalData: {
nachname: decryptedData.nachname,
vorname: decryptedData.vorname,
email: decryptedData.email,
telefon_privat: decryptedData.telefon_privat,
telefon_mobil: decryptedData.telefon_mobil
}
})
} catch (error) {
console.error(`Fehler beim Laden von ${file}:`, error)
}
}
}
// Nach Zeitstempel sortieren (neueste zuerst)
applications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
return applications
} catch (error) {
console.error('Fehler beim Laden der Mitgliedschaftsanträge:', error)
throw createError({
statusCode: 500,
statusMessage: 'Fehler beim Laden der Anträge'
})
}
})

View File

@@ -0,0 +1,100 @@
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// Datei-ID aus der URL extrahieren
const fileId = decodeURIComponent(getRouterParam(event, 'id'))
if (!fileId) {
throw createError({
statusCode: 400,
statusMessage: 'Datei-ID fehlt'
})
}
// Upload-Verzeichnis finden
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
console.log('Upload-Verzeichnis:', uploadDir)
// Alle Dateien im Upload-Verzeichnis durchsuchen
const files = await fs.readdir(uploadDir)
console.log('Verfügbare Dateien:', files)
console.log('Gesuchte Datei-ID:', fileId)
const matchingFile = files.find(file => file.includes(fileId))
console.log('Gefundene Datei:', matchingFile)
if (!matchingFile) {
throw createError({
statusCode: 404,
statusMessage: 'Datei nicht gefunden'
})
}
// Prüfen ob der Benutzer berechtigt ist, diese Datei herunterzuladen
const token = getCookie(event, 'auth_token')
let isAuthorized = false
if (token) {
// Authentifizierte Benutzer prüfen
const user = await getUserFromToken(token)
if (user && ['admin', 'vorstand'].includes(user.role)) {
// Admin/Vorstand kann alle Dateien herunterladen
isAuthorized = true
}
}
// Prüfen ob es sich um eine aktuelle Session handelt (innerhalb der letzten 30 Minuten)
const sessionKey = `download_${fileId}`
const sessionValue = getCookie(event, sessionKey)
if (sessionValue === 'authorized') {
// Session-basierte Berechtigung für Antragsteller
isAuthorized = true
}
if (!isAuthorized) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung für diesen Download'
})
}
const filePath = path.join(uploadDir, matchingFile)
// Datei lesen
const fileBuffer = await fs.readFile(filePath)
// MIME-Type basierend auf Dateiendung bestimmen
const ext = path.extname(matchingFile).toLowerCase()
let mimeType = 'application/octet-stream'
let filename = matchingFile
if (ext === '.pdf') {
mimeType = 'application/pdf'
} else if (ext === '.txt') {
mimeType = 'text/plain'
filename = matchingFile.replace('.txt', '.pdf') // Für Download als PDF benennen
}
// Datei als Download senden
setHeader(event, 'Content-Type', mimeType)
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
setHeader(event, 'Content-Length', fileBuffer.length.toString())
return fileBuffer
} catch (error) {
console.error('Download-Fehler:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Interner Serverfehler'
})
}
})

View File

@@ -0,0 +1,708 @@
import { createRequire } from 'module'
import { exec } from 'child_process'
import { promisify } from 'util'
import fs from 'fs/promises'
import path from 'path'
import { encrypt } from '../../utils/encryption.js'
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'
const require = createRequire(import.meta.url)
const execAsync = promisify(exec)
function generateLaTeXContent(data) {
const heute = new Date().toLocaleDateString('de-DE')
// LaTeX-Inhalt mit korrekten Escapes generieren
let latexContent = `\\documentclass[12pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[ngerman]{babel}
\\usepackage{geometry}
\\usepackage{enumitem}
\\usepackage{xcolor}
\\usepackage{helvet} % Für Sans-Serif Schriftart
\\renewcommand{\\familydefault}{\\sfdefault} % Setzt Sans-Serif als Standard
\\setlength{\\parindent}{0pt} % Keine Absatzeinrückung
\\geometry{margin=2cm}
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
\\vspace{0.3cm}
\\Large Beitrittserklärung}
\\date{}
\\begin{document}
\\maketitle
\\vspace{0.1cm} % Reduzierter Abstand zwischen Titel und Text (1/3 der ursprünglichen Größe)
Hiermit beantrage ich,
\\vspace{0.5cm}
Name: \\underline{${data.nachname} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Vorname: \\underline{${data.vorname} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Straße: \\underline{${data.strasse} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
PLZ/Wohnort: \\underline{${data.plz} ${data.ort} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Geburtsdatum: \\underline{${new Date(data.geburtsdatum).toLocaleDateString('de-DE')} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Telefon (privat): \\underline{${data.telefon_privat || ''} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
E-Mail: \\underline{${data.email} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Telefon (Mobil): \\underline{${data.telefon_mobil || ''} \\hspace{6cm}}
\\vspace{0.5cm}
meine Aufnahme in den Harheimer Tischtennis-Club 1954 e.V. als
\\vspace{0.3cm}
\\begin{itemize}[label={}]
\\item[\\framebox(4,4){}] ${data.mitgliedschaftsart === 'aktiv' ? '\\framebox(4,4){X}' : '\\framebox(4,4){}'} aktives Mitglied
\\item[\\framebox(4,4){}] ${data.mitgliedschaftsart === 'passiv' ? '\\framebox(4,4){X}' : '\\framebox(4,4){}'} passives Mitglied
\\end{itemize}
\\vspace{0.5cm}
Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von
\\begin{itemize}
\\item € 120,-- (Erwachsene)
\\item € 72,-- (Jugendliche bis zum vollendeten 18. Lebensjahr)
\\item € 30,-- (passive Mitglieder)
\\end{itemize}
bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.
Hierzu erteile ich beigefügtes SEPA-Lastschriftmandat.
\\vspace{0.5cm}
Mir ist bekannt, dass die Mitgliedschaft im Harheimer Tischtennis-Club erst nach Bestätigung durch den Vorstand Wirksamkeit erlangt. Die Beitragspflicht beginnt mit dem darauf folgenden Monat.
Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.
\\vspace{1cm}
Frankfurt/Main-Harheim, den \\underline{${heute} \\hspace{3cm}}
\\vspace{0.5cm}
Unterschrift ${data.isVolljaehrig ? '' : '(bei Jugendlichen gesetzlicher Vertreter)'}
\\newpage
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
\\Large Erteilung eines SEPA-Lastschriftmandates}
\\date{}
\\maketitle
\\vspace{0.5cm}
\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362
\\vspace{0.5cm}
Hiermit ermächtige ich den Harheimer Tischtennis-Club 1954 e.V. die jährlichen Mitgliedsbeiträge von meinem untenstehenden Konto per Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die vom Harheimer Tischtennis-Club 1954 e.V. auf mein Konto gezogenen Lastschriften einzulösen.
\\vspace{0.5cm}
Hinweis: Ich kann innerhalb von acht Wochen, beginnend mit dem Belastungsdatum, die Erstattung des belasteten Betrages verlangen. Es gelten dabei die mit meinem Kreditinstitut vereinbarten Bedingungen.
\\vspace{1cm}
Kontoinhaber: \\underline{${data.kontoinhaber} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
IBAN: \\underline{${data.iban} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
BIC: \\underline{${data.bic || ''} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Kreditinstitut: \\underline{${data.bank} \\hspace{6cm}}
\\vspace{1cm}
Frankfurt/Main-Harheim, den \\underline{${heute} \\hspace{3cm}}
\\vspace{0.5cm}
Unterschrift des Kontoinhabers
\\newpage
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
\\Large Einwilligungserklärung}
\\date{}
\\maketitle
\\vspace{0.5cm}
\\textbf{für die Veröffentlichung von Mitgliederdaten im Internet.}
\\vspace{0.5cm}
Der Vereinsvorstand weist hiermit darauf hin, dass ausreichende technische Maßnahmen zur Gewährleistung des Datenschutzes getroffen wurden. Dennoch kann bei einer Veröffentlichung von personenbezogenen Mitgliederdaten im Internet ein umfassender Datenschutz nicht garantiert werden. Daher nimmt das Vereinsmitglied die Risiken für eine eventuelle Persönlichkeitsrechtsverletzung zur Kenntnis und ist sich bewusst, dass:
\\begin{itemize}
\\item die personenbezogenen Daten auch in Staaten abrufbar sind, die keine der Bundesrepublik Deutschland vergleichbaren Datenschutzbestimmungen kennen,
\\item die Vertraulichkeit, die Integrität (Unverletzlichkeit), die Authentizität (Echtheit) und die Verfügbarkeit der personenbezogenen Daten nicht garantiert ist.
\\end{itemize}
Das Vereinsmitglied trifft die Entscheidung zur Veröffentlichung seiner Daten im Internet freiwillig und kann seine Einwilligung gegenüber dem Vereinsvorstand jederzeit widerrufen.
\\vspace{0.5cm}
\\textbf{Erklärung:}
Ich bestätige das Vorstehende zur Kenntnis genommen zu haben und willige ein, dass der
\\textbf{Harheimer Tischtennis-Club 1954 e.V.}
folgende allgemeine Daten zu meiner Person:
Vorname: \\underline{${data.vorname} \\hspace{4cm}} \\\\
\\vspace{0.3cm}
Zuname: \\underline{${data.nachname} \\hspace{4cm}}
Fotografien, sonstige Daten (Mannschaft, Leistungsergebnisse, Turnierteilnahmen, Lizenzen u.ä.) bzw. spezielle Daten von Funktionsträgern:
Anschrift: \\underline{${data.strasse}, ${data.plz} ${data.ort} \\hspace{4cm}} \\\\
\\vspace{0.3cm}
Telefonnummer: \\underline{${data.telefon_privat || data.telefon_mobil || ''} \\hspace{4cm}} \\\\
\\vspace{0.3cm}
E-Mail-Adresse: \\underline{${data.email} \\hspace{4cm}}
wie angegeben auf der Homepage des Vereins (www.harheimertc.de) veröffentlichen darf.
\\vspace{1cm}
Datum: \\underline{${heute} \\hspace{6cm}}
\\vspace{0.5cm}
Unterschrift ${data.isVolljaehrig ? '' : '(bei Minderjährigen Unterschrift eines Erziehungsberechtigten)'}
\\end{document}`
// Doppelte Backslashes zu einfachen Backslashes konvertieren, aber \\vspace und \\hspace beibehalten
// Erst alle \\vspace und \\hspace temporär ersetzen
let result = latexContent.replace(/\\\\vspace/g, 'TEMP_VSPACE')
result = result.replace(/\\\\hspace/g, 'TEMP_HSPACE')
// Dann alle anderen doppelten Backslashes ersetzen
result = result.replace(/\\\\/g, '\\')
// Dann die temporären Platzhalter wieder zurückersetzen
result = result.replace(/TEMP_VSPACE/g, '\\vspace')
result = result.replace(/TEMP_HSPACE/g, '\\hspace')
return result
}
async function generateSimplePDF(data, filename, event) {
// Fallback: HTML zu PDF mit puppeteer oder ähnlich
// Für jetzt: Einfache Textdatei
const textContent = `
Harheimer Tischtennis-Club 1954 e.V.
Beitrittserklärung
Antragsteller: ${data.vorname} ${data.nachname}
Mitgliedschaftsart: ${data.mitgliedschaftsart}
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
const textPath = path.join(process.cwd(), 'public', 'uploads', `${filename}.txt`)
await fs.writeFile(textPath, textContent, 'utf8')
return `${filename}.txt`
}
function getDataPath(filename) {
// Immer den absoluten Pfad zum Projekt-Root verwenden
// 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(), '..')
return path.join(projectRoot, 'server', 'data', filename)
}
async function sendMembershipEmail(data, filename, event) {
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) => {
try {
const body = await readBody(event)
// Validierung der Eingabedaten
const requiredFields = ['vorname', 'nachname', 'strasse', 'plz', 'ort', 'geburtsdatum', 'email', 'mitgliedschaftsart', 'kontoinhaber', 'iban']
for (const field of requiredFields) {
if (!body[field]) {
throw createError({
statusCode: 400,
statusMessage: `Feld '${field}' ist erforderlich`
})
}
}
// Volljährigkeit prüfen
const birthDate = new Date(body.geburtsdatum)
const today = new Date()
const age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
const isVolljaehrig = age > 18 || (age === 18 && monthDiff >= 0)
const data = {
...body,
isVolljaehrig
}
// Eindeutige Datei-ID generieren
const timestamp = Date.now()
const filename = `beitrittserklärung_${timestamp}`
// Temp-Verzeichnis erstellen
const tempDir = path.join(process.cwd(), '.output', 'temp', 'latex')
await fs.mkdir(tempDir, { recursive: true })
try {
// PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
async function fillPdfTemplate(data) {
// 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 localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf')
let arrayBuffer
try {
const localExists = await fs.stat(localPath).then(() => true).catch(() => false)
if (localExists) {
const buf = await fs.readFile(localPath)
arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
} else {
const TEMPLATE_URL = process.env.MEMBERSHIP_TEMPLATE_URL || 'https://harheimertc.de/Aufnahmeantrag%202025.pdf'
const res = await fetch(TEMPLATE_URL)
if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`)
arrayBuffer = await res.arrayBuffer()
}
} catch (e) {
throw new Error('Template-Laden fehlgeschlagen: ' + e.message)
}
const pdfDoc = await PDFDocument.load(arrayBuffer)
let form
try {
form = pdfDoc.getForm()
} catch (e) {
form = null
}
if (!form || form.getFields().length === 0) {
// Keine Formularfelder vorhanden -> positional filler verwenden
// Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden.
const pages = pdfDoc.getPages()
const firstPage = pages[0]
const { width, height } = firstPage.getSize()
// Schätzwerte: (x, y) in Punkten von linker unteren Ecke
// Diese Werte müssen nach Sichtprüfung justiert werden.
// Angepasste Koordinaten — Text beginnt links an der Linie und sitzt oberhalb der Unterstreichung
// Linke Spalte (nachname, vorname, etc.) etwas weiter links setzen
const leftX = 156
// Rechte Spalte (PLZ/Ort, Telefon, E-Mail) weiter nach rechts für klare Trennung
const rightX = 470
// Baseline für erstes Feld (von oben aus gerechnet)
const baseY = height - 160
const gap = 28
// Kleine vertikale Verschiebung, damit Texte oberhalb der Unterstreichung beginnen
const yOffset = 9
const coords = {
// Row: Name (left) / Vorname (right)
nachname: { x: leftX, y: baseY + yOffset },
vorname: { x: rightX, y: baseY + yOffset },
// Row: Straße (left) / PLZ/Ort (right)
strasse: { x: leftX, y: baseY - gap + yOffset },
plz_ort: { x: rightX, y: baseY - gap + yOffset },
// Row: Geburtsdatum (left) / Telefon(privat) (right)
geburtsdatum: { x: leftX, y: baseY - gap * 2 + yOffset },
telefon: { x: rightX, y: baseY - gap * 2 + yOffset },
// Row: E-Mail (left) / Telefon(Mobil) (right)
email: { x: leftX, y: baseY - gap * 3 + yOffset },
telefon_mobil: { x: rightX, y: baseY - gap * 3 + yOffset },
// Membership checkbox positions (approx.)
mitglied_checkbox_aktiv: { x: leftX - 40, y: baseY - gap * 6 + yOffset },
mitglied_checkbox_passiv: { x: leftX - 40, y: baseY - gap * 7 + yOffset },
// Account details on subsequent page(s)
kontoinhaber: { x: leftX, y: baseY - gap * 12 + yOffset },
iban: { x: leftX, y: baseY - gap * 13 + yOffset },
bic: { x: leftX, y: baseY - gap * 14 + yOffset },
bank: { x: leftX, y: baseY - gap * 15 + 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)
const helveticaFont = await pdfDoc.embedFont(PDFDocument.PDFName ? 'Helvetica' : '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
try {
// Zeichne Name / Vorname / Adresse / Kontakt (einmal)
firstPage.drawText(data.nachname || '', { x: coords.nachname.x, y: coords.nachname.y, size: 11, font: helveticaFont })
firstPage.drawText(data.vorname || '', { x: coords.vorname.x, y: coords.vorname.y, size: 11, font: helveticaFont })
firstPage.drawText(data.strasse || '', { x: coords.strasse.x, y: coords.strasse.y, size: 11, font: helveticaFont })
firstPage.drawText(`${data.plz || ''} ${data.ort || ''}`.trim(), { x: coords.plz_ort.x, y: coords.plz_ort.y, size: 11, font: helveticaFont })
firstPage.drawText(new Date(data.geburtsdatum).toLocaleDateString('de-DE') || '', { x: coords.geburtsdatum.x, y: coords.geburtsdatum.y, size: 11, font: helveticaFont })
firstPage.drawText(data.telefon_privat || data.telefon_mobil || '', { x: coords.telefon.x, y: coords.telefon.y, size: 11, font: helveticaFont })
firstPage.drawText(data.email || '', { x: coords.email.x, y: coords.email.y, size: 11, font: helveticaFont })
firstPage.drawText(data.telefon_mobil || '', { x: coords.telefon_mobil.x, y: coords.telefon_mobil.y, size: 11, font: helveticaFont })
// Kontodaten evtl. auf andere Seite: falls mehrere Seiten vorhanden, nutze last page
const lastPage = pages[pages.length - 1]
lastPage.drawText(data.kontoinhaber || '', { x: coords.kontoinhaber.x, y: coords.kontoinhaber.y, size: 11, font: helveticaFont })
lastPage.drawText(data.iban || '', { x: coords.iban.x, y: coords.iban.y, size: 11, font: helveticaFont })
lastPage.drawText(data.bic || '', { x: coords.bic.x, y: coords.bic.y, size: 11, font: helveticaFont })
lastPage.drawText(data.bank || '', { x: coords.bank.x, y: coords.bank.y, size: 11, font: helveticaFont })
// Zeichne X in die passende Mitgliedschafts-Checkbox
try {
if (data.mitgliedschaftsart === 'aktiv') {
firstPage.drawText('X', { x: coords.mitglied_checkbox_aktiv.x, y: coords.mitglied_checkbox_aktiv.y, size: 12, font: helveticaFont })
} else if (data.mitgliedschaftsart === 'passiv') {
firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont })
}
} catch (e) {
console.warn('Fehler beim Zeichnen der Checkbox:', e.message)
}
// Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true
if (data && data.debug) {
try {
// Auffälliges Debug-Tag oben links
const tagW = 100
const tagH = 22
firstPage.drawRectangle({ x: 40, y: height - 60, width: tagW, height: tagH, color: rgb(1, 0, 0), opacity: 0.25 })
firstPage.drawText('DEBUG', { x: 48, y: height - 56, size: 12, color: rgb(1, 0, 0), font: helveticaFont })
// Markiere alle Koordinaten mit einem gefüllten roten Quadrat und Label
const allCoords = Object.entries(coords)
for (const [key, c] of allCoords) {
// small filled square
firstPage.drawRectangle({ x: c.x - 3, y: c.y - 3, width: 8, height: 8, color: rgb(1, 0, 0), opacity: 1 })
// 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 })
}
} catch (e) {
console.warn('Debug overlay fehlgeschlagen:', e.message)
}
}
} catch (e) {
console.warn('Fehler beim positional drawing:', e.message)
}
const pdfBytes = await pdfDoc.save()
return Buffer.from(pdfBytes)
}
// Wenn Formularfelder existieren: befülle sie per AcroForm
const fields = form.getFields()
if (fields && fields.length > 0) {
try {
const byName = {}
for (const f of fields) byName[f.getName().toLowerCase()] = f
const setIf = (name, value) => {
const f = byName[name]
if (!f) return
try {
if (typeof f.setText === 'function') f.setText(String(value || ''))
else if (typeof f.check === 'function' && (value === true || String(value).toLowerCase() === 'true')) f.check()
} catch (e) {
console.warn('Fehler beim Setzen Feld', name, e.message)
}
}
setIf('nachname', data.nachname)
setIf('vorname', data.vorname)
setIf('strasse', data.strasse)
setIf('plz_ort', `${data.plz || ''} ${data.ort || ''}`.trim())
setIf('geburtsdatum', new Date(data.geburtsdatum).toLocaleDateString('de-DE'))
setIf('telefon', data.telefon_privat || data.telefon_mobil)
setIf('email', data.email)
setIf('telefon_mobil', data.telefon_mobil)
// Checkboxes
if (byName['mitglied_aktiv'] && data.mitgliedschaftsart === 'aktiv') byName['mitglied_aktiv'].check && byName['mitglied_aktiv'].check()
if (byName['mitglied_passiv'] && data.mitgliedschaftsart === 'passiv') byName['mitglied_passiv'].check && byName['mitglied_passiv'].check()
const pdfBytes = await pdfDoc.save()
return Buffer.from(pdfBytes)
} catch (e) {
console.warn('AcroForm-Füllung fehlgeschlagen, fallback auf positional:', e.message)
}
}
const mapValue = (name) => {
// einfache Heuristiken für Feldnamen
name = name.toLowerCase()
if (name.includes('nachname') || name.includes('zuname') || name.includes('name')) return data.nachname || ''
if (name.includes('vorname') || name.includes('given')) return data.vorname || ''
if (name.includes('str') || name.includes('straße') || name.includes('street')) return data.strasse || ''
if (name.includes('plz')) return data.plz || ''
if (name.includes('ort') || name.includes('stadt')) return data.ort || ''
if (name.includes('geb') || name.includes('geburts')) return new Date(data.geburtsdatum).toLocaleDateString('de-DE')
if (name.includes('telefon') || name.includes('tel')) return data.telefon_privat || data.telefon_mobil || ''
if (name.includes('email')) return data.email || ''
if (name.includes('kontoinhaber') || name.includes('kontoinh')) return data.kontoinhaber || ''
if (name.includes('iban')) return data.iban || ''
if (name.includes('bic')) return data.bic || ''
if (name.includes('bank') || name.includes('kreditinstitut')) return data.bank || ''
if (name.includes('mitglied') || name.includes('mitgliedschaft') || name.includes('art')) return data.mitgliedschaftsart || ''
return ''
}
for (const field of fields) {
const fname = field.getName()
const lower = fname.toLowerCase()
try {
// Textfelder
if (typeof field.setText === 'function') {
const val = mapValue(lower)
field.setText(val)
continue
}
// Checkbox / Radio
if (typeof field.check === 'function' || typeof field.isChecked === 'function') {
// einfache Heuristik: bei Mitgliedschaftsart
if (lower.includes('aktiv') || lower.includes('passiv') || lower.includes('mitglied')) {
if (data.mitgliedschaftsart && lower.includes(data.mitgliedschaftsart)) {
field.check && field.check()
} else {
if (lower.includes('aktiv') && data.mitgliedschaftsart === 'aktiv') field.check && field.check()
if (lower.includes('passiv') && data.mitgliedschaftsart === 'passiv') field.check && field.check()
}
continue
}
const mapped = mapValue(lower)
if (mapped === 'true' || mapped === 'ja' || mapped === 'checked') {
field.check && field.check()
}
}
} catch (e) {
console.warn('Fehler beim Befüllen Feld', fname, e.message)
}
}
const pdfBytes = await pdfDoc.save()
return Buffer.from(pdfBytes)
}
let usedTemplate = false
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
await fs.mkdir(uploadsDir, { recursive: true })
try {
const filled = await fillPdfTemplate(data)
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.writeFile(finalPdfPath, filled)
// Zusätzlich: Kopie ins repo-root public/uploads legen, falls Nitro cwd anders ist
try {
const repoRoot = path.resolve(process.cwd(), '..')
const repoUploads = path.join(repoRoot, 'public', 'uploads')
await fs.mkdir(repoUploads, { recursive: true })
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
} catch (e) {
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
}
usedTemplate = true
} catch (templateError) {
// Template konnte nicht verwendet werden -> weiter zum LaTeX-Fallback
console.warn('Template-Füllung fehlgeschlagen, fahre mit LaTeX fort:', templateError.message)
}
let emailResult
if (usedTemplate) {
// E-Mail senden
emailResult = await sendMembershipEmail(data, filename, event)
// Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
const dataPath = path.join(uploadsDir, `${filename}.data`)
await fs.writeFile(dataPath, encryptedData, 'utf8')
// Download-Token setzen
const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64')
setCookie(event, 'download_token', downloadToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 // 24 Stunden
})
return {
success: true,
message: 'Beitrittsformular erfolgreich aus Template erzeugt und E-Mail gesendet.',
downloadUrl: `/api/membership/download/${filename}.pdf`,
emailSuccess: emailResult.success,
emailMessage: emailResult.message,
usedTemplate: true
}
}
// Falls Template nicht verwendet: weiter mit LaTeX-Fallback
// LaTeX-Inhalt generieren
const latexContent = generateLaTeXContent(data)
// LaTeX-Datei schreiben
const texPath = path.join(tempDir, `${filename}.tex`)
await fs.writeFile(texPath, latexContent, 'utf8')
// PDF mit pdflatex generieren
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
await execAsync(command)
// PDF-Datei in Uploads-Verzeichnis kopieren
const pdfPath = path.join(tempDir, `${filename}.pdf`)
await fs.mkdir(uploadsDir, { recursive: true })
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.copyFile(pdfPath, finalPdfPath)
// Kopie ins repo-root public/uploads für bessere Auffindbarkeit
try {
const repoRoot = path.resolve(process.cwd(), '..')
const repoUploads = path.join(repoRoot, 'public', 'uploads')
await fs.mkdir(repoUploads, { recursive: true })
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
} catch (e) {
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
}
// E-Mail senden
emailResult = await sendMembershipEmail(data, filename, event)
// Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
const dataPath = path.join(uploadsDir, `${filename}.data`)
await fs.writeFile(dataPath, encryptedData, 'utf8')
// Download-Berechtigung für den Antragsteller setzen
const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64')
setCookie(event, 'download_token', downloadToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 // 24 Stunden
})
return {
success: true,
message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet.',
downloadUrl: `/api/membership/download/${filename}.pdf`,
emailSuccess: emailResult.success,
emailMessage: emailResult.message,
}
} catch (latexError) {
console.error('LaTeX-Fehler:', latexError)
// Fallback: Einfache Textdatei generieren
const fallbackFilename = await generateSimplePDF(data, filename, event)
// E-Mail senden (Fallback)
const emailResult = await sendMembershipEmail(data, filename, event)
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
console.log('Betreff:', emailResult.subject || '')
console.log('Nachricht:', emailResult.message || '')
console.log('Upload-Verzeichnis:', path.join(process.cwd(), 'public', 'uploads'))
// Verfügbare Dateien auflisten
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
try {
const files = await fs.readdir(uploadsDir)
console.log('Verfügbare Dateien:', files)
// Gesuchte Datei finden
const targetFile = files.find(file => file.startsWith(filename))
console.log('Gesuchte Datei-ID:', filename)
console.log('Gefundene Datei:', targetFile || 'Nicht gefunden')
} catch (dirError) {
console.error('Fehler beim Lesen des Upload-Verzeichnisses:', dirError)
}
return {
success: true,
message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet (Fallback-Lösung).',
downloadUrl: `/api/membership/download/${fallbackFilename}`,
emailSuccess: emailResult.success,
emailMessage: emailResult.message,
}
}
} catch (error) {
console.error('Fehler beim Generieren des PDFs:', error)
throw createError({
statusCode: 500,
statusMessage: 'Fehler beim Generieren des PDFs'
})
}
})

View File

@@ -0,0 +1,90 @@
import fs from 'fs/promises'
import path from 'path'
import { decryptObject, encryptObject } from '../../utils/encryption.js'
import { saveMember } from '../../utils/members.js'
export default defineEventHandler(async (event) => {
try {
const { id, status, notes } = await readBody(event)
if (!id || !status) {
throw createError({
statusCode: 400,
statusMessage: 'ID und Status sind erforderlich'
})
}
if (!['pending', 'approved', 'rejected'].includes(status)) {
throw createError({
statusCode: 400,
statusMessage: 'Ungültiger Status'
})
}
const config = useRuntimeConfig()
const encryptionKey = config.encryptionKey
if (!encryptionKey) {
throw createError({
statusCode: 500,
statusMessage: 'Verschlüsselungsschlüssel nicht konfiguriert'
})
}
const dataDir = path.join(process.cwd(), 'server/data/membership-applications')
const filePath = path.join(dataDir, `${id}.json`)
// Antrag laden
const fileContent = await fs.readFile(filePath, 'utf8')
const applicationData = JSON.parse(fileContent)
// Status aktualisieren
applicationData.status = status
applicationData.updatedAt = new Date().toISOString()
if (notes) {
applicationData.notes = notes
}
// Wenn genehmigt: In Mitgliederliste einfügen
if (status === 'approved') {
try {
const decryptedData = decryptObject(applicationData.encryptedData, encryptionKey)
const newMember = {
firstName: decryptedData.vorname,
lastName: decryptedData.nachname,
email: decryptedData.email,
phone: decryptedData.telefon_privat || decryptedData.telefon_mobil || '',
address: `${decryptedData.strasse}, ${decryptedData.plz} ${decryptedData.ort}`,
notes: `Mitgliedschaftsart: ${applicationData.metadata.mitgliedschaftsart} | Genehmigt: ${new Date().toLocaleDateString('de-DE')}`,
source: 'membership_application',
applicationId: id
}
await saveMember(newMember)
applicationData.memberId = newMember.id
console.log(`Mitgliedschaftsantrag ${id} wurde genehmigt und in Mitgliederliste eingefügt`)
} catch (error) {
console.error('Fehler beim Einfügen in Mitgliederliste:', error)
throw error
}
}
// Speichern
await fs.writeFile(filePath, JSON.stringify(applicationData, null, 2), 'utf8')
return {
success: true,
message: status === 'approved' ? 'Antrag genehmigt und in Mitgliederliste eingefügt' : 'Status erfolgreich aktualisiert'
}
} catch (error) {
console.error('Fehler beim Aktualisieren des Status:', error)
throw createError({
statusCode: 500,
statusMessage: 'Fehler beim Aktualisieren des Status'
})
}
})

View File

@@ -103,5 +103,12 @@
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMTM2NzMzLCJleHAiOjE3NjE3NDE1MzN9.rzxassQ4Uj-nXfL2y1sygzshW0YovdYR2GUjosXoPF8",
"createdAt": "2025-10-22T12:38:53.918Z",
"expiresAt": "2025-10-29T12:38:53.918Z"
},
{
"id": "1761144008551",
"userId": "1",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMTQ0MDA4LCJleHAiOjE3NjE3NDg4MDh9.G5OtippK77mTioWV8Js-OpoJmaEPLA38VRjwRRj93oA",
"createdAt": "2025-10-22T14:40:08.551Z",
"expiresAt": "2025-10-29T14:40:08.551Z"
}
]

View File

@@ -8,6 +8,6 @@
"phone": "",
"active": true,
"created": "2025-10-21T00:00:00.000Z",
"lastLogin": "2025-10-22T12:38:53.919Z"
"lastLogin": "2025-10-22T14:40:08.552Z"
}
]

Binary file not shown.

Binary file not shown.

102
server/utils/encryption.js Normal file
View File

@@ -0,0 +1,102 @@
import crypto from 'crypto'
// Verschlüsselungskonfiguration
const ALGORITHM = 'aes-256-cbc'
const IV_LENGTH = 16
const SALT_LENGTH = 32
/**
* Generiert einen Schlüssel aus einem Passwort und Salt
*/
function deriveKey(password, salt) {
return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512')
}
/**
* Verschlüsselt einen Text
*/
export function encrypt(text, password) {
try {
// Salt generieren
const salt = crypto.randomBytes(SALT_LENGTH)
// Schlüssel ableiten
const key = deriveKey(password, salt)
// IV generieren
const iv = crypto.randomBytes(IV_LENGTH)
// Cipher erstellen
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
// Verschlüsseln
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
// Salt + IV + Verschlüsselter Text kombinieren
const combined = Buffer.concat([
salt,
iv,
Buffer.from(encrypted, 'hex')
])
return combined.toString('base64')
} catch (error) {
console.error('Verschlüsselungsfehler:', error)
throw new Error('Fehler beim Verschlüsseln der Daten')
}
}
/**
* Entschlüsselt einen Text
*/
export function decrypt(encryptedData, password) {
try {
// Base64 dekodieren
const combined = Buffer.from(encryptedData, 'base64')
// Komponenten extrahieren
const salt = combined.subarray(0, SALT_LENGTH)
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH)
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH)
// Schlüssel ableiten
const key = deriveKey(password, salt)
// Decipher erstellen
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
// Entschlüsseln
let decrypted = decipher.update(encrypted, null, 'utf8')
decrypted += decipher.final('utf8')
return decrypted
} catch (error) {
console.error('Entschlüsselungsfehler:', error)
throw new Error('Fehler beim Entschlüsseln der Daten')
}
}
/**
* Verschlüsselt ein Objekt (konvertiert zu JSON)
*/
export function encryptObject(obj, password) {
const jsonString = JSON.stringify(obj)
return encrypt(jsonString, password)
}
/**
* Entschlüsselt ein Objekt (konvertiert von JSON)
*/
export function decryptObject(encryptedData, password) {
const jsonString = decrypt(encryptedData, password)
return JSON.parse(jsonString)
}
/**
* Generiert einen sicheren Schlüssel für die Datenverschlüsselung
*/
export function generateEncryptionKey() {
return crypto.randomBytes(32).toString('hex')
}

Binary file not shown.

26
temp/latex/test.tex Normal file
View File

@@ -0,0 +1,26 @@
\documentclass[12pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[ngerman]{babel}
\usepackage{geometry}
\usepackage{enumitem}
\usepackage{xcolor}
\usepackage{helvet} % Für Sans-Serif Schriftart
\renewcommand{\familydefault}{\sfdefault} % Setzt Sans-Serif als Standard
\setlength{\parindent}{0pt} % Keine Absatzeinrückung
\geometry{margin=2cm}
\title{\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\
\vspace{1cm}
\Large Erteilung eines SEPA-Lastschriftmandates}
\date{}
\begin{document}
\maketitle
\vspace{1cm}
\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\
Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362
\end{document}

22
test-vspace.tex Normal file
View File

@@ -0,0 +1,22 @@
\documentclass[12pt,a4paper]{article}
\usepackage[utf8]{inputenc}
\usepackage[ngerman]{babel}
\usepackage{geometry}
\geometry{margin=2cm}
\title{\textbf{Test}\\
\vspace{1cm}
\Large Test-Titel}
\date{}
\begin{document}
\maketitle
\vspace{1cm}
Dies ist der Inhalt nach dem Header.
\end{document}

2
test.aux Normal file
View File

@@ -0,0 +1,2 @@
\relax
\gdef \@abspage@last{1}

BIN
test.pdf Normal file

Binary file not shown.

1
test.tex Normal file
View File

@@ -0,0 +1 @@
\documentclass{article}\begin{document}Test\end{document}