88 Commits

Author SHA1 Message Date
Torsten Schulz (local)
fddde56076 Update package version to 1.1.6 and enhance deploy script with dependency checks
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m29s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m7s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 11s
- Bumped the package version to 1.1.6 in package.json.
- Added `install_dependencies_if_needed` function in deploy-test.sh to conditionally install dependencies based on the state of package-lock.json.
- Improved logging of build output to a file for better error tracking during deployment.
- Updated Nuxt cache handling to retain .nuxt directory for faster builds unless explicitly cleaned.
2026-05-06 16:03:16 +02:00
Torsten Schulz (local)
c385df4a0c Add dependency installation check and logging to deploy script
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m54s
- Introduced `install_dependencies_if_needed` function to conditionally install dependencies based on the presence and changes in `package-lock.json`.
- Updated the deployment process to log build output to a file for better error tracking.
- Modified Nuxt configuration to disable source maps in production and prevent reporting of compressed sizes in Vite builds.
2026-05-06 15:58:02 +02:00
Torsten Schulz (local)
e44d3c5c74 Update package version to 1.1.5 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m5s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m35s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Successful in 11s
2026-05-05 15:14:38 +02:00
Torsten Schulz (local)
c409fa6d4b Update candidate paths for CSV file retrieval in mannschaften.get.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
- Adjusted the logic to prioritize the new CMS write target for public data.
- Updated comments to clarify the order of candidate paths for file retrieval.
2026-05-05 15:13:22 +02:00
Torsten Schulz (local)
0fa19493c5 Refactor readPackageVersion function to support multiple candidate paths for package.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m58s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
- Updated the logic to read the package version from either the current directory or the parent directory.
- Added error handling to continue searching through candidate paths if the first read fails.
2026-04-27 16:52:12 +02:00
Torsten Schulz (local)
c145a723ed Update package version to 1.1.4 in package.json and package-lock.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
2026-04-27 16:47:57 +02:00
Torsten Schulz (local)
d0b15f3e83 Update package version to 1.1.3 and postcss dependency to 8.5.12 in package.json and package-lock.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m4s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 3m25s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 9s
2026-04-27 16:40:33 +02:00
Torsten Schulz (local)
e60c0f4481 Add logic to include active trainers as newsletter recipients
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 3m16s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Successful in 11s
- Enhanced the getRecipientsByGroup function to filter and add active trainers from users.json to the newsletter recipients list.
- Ensured that duplicate emails are not added to the recipients array.
2026-04-27 15:10:57 +02:00
Torsten Schulz (local)
27a096546f Implement user sorting feature in Benutzer.vue
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
- Added a dropdown for sorting active users by first name or last name.
- Updated the display of active users to reflect the selected sorting order.
- Introduced helper functions to split names and format display names accordingly.
2026-04-27 15:04:41 +02:00
Torsten Schulz (local)
20a1cdd7f2 Update package version to 1.1.2 in package.json and modify code-analysis.yml to trigger analysis only on pull requests.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m49s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 7s
2026-04-16 13:53:31 +02:00
Torsten Schulz (local)
e3825ad217 Update package version to 1.1.1 in package.json for the Harheimer Tischtennis Club website.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-04-16 13:45:02 +02:00
Torsten Schulz (local)
a12f1f7815 Remove package version change requirement for main PRs in code-analysis.yml to streamline workflow.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m54s
Code Analysis and Production Deploy / analyze (pull_request) Successful in 2m49s
Require Package Version Change / check (pull_request) Failing after 8s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
2026-04-16 13:36:45 +02:00
Torsten Schulz (local)
6fea2749e0 Add app version display in Footer and implement version API endpoint
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m54s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 11s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
- Updated Footer.vue to show the application version for logged-in users.
- Added a new API endpoint to return the application version from package.json.
- Enhanced code-analysis.yml to require package version changes for main PRs.
2026-04-16 13:16:53 +02:00
Torsten Schulz (local)
18da725567 Refactor deployment scripts to use git fetch and reset for pulling latest changes. Update deploy-production.sh and deploy-test.sh to ensure a clean state before deployment. Modify code-analysis.yml to reflect these changes in deployment commands.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m58s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-04-16 13:11:23 +02:00
Torsten Schulz (local)
4d5fb43ebc Enhance deploy-test.sh with functions for Node.js version management, dependency installation, and public document synchronization. Implement checks for Node.js version requirements and improve error handling for document syncing. Update environment configuration in harheimertc.test.config.cjs to support development and test environments. Modify email recipient logic in contact and email service APIs to prevent notifications in test environments. Add tests to verify behavior in test conditions.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m52s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 12s
2026-04-16 13:06:14 +02:00
Torsten Schulz (local)
986b2056cd Enhance deploy-production.sh with new functions for Node.js version management and public document synchronization. Added checks for Node.js version requirements and improved error handling for document syncing. Updated package.json and package-lock.json to specify Node.js and npm engine requirements.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m57s
2026-04-15 21:51:08 +02:00
Torsten Schulz (local)
337c172d07 Refactor dependency installation in deploy-production.sh to use a dedicated function. This improves error handling for missing package-lock.json and ensures consistent installation behavior. Removed obsolete public-data restoration logic for cleaner script execution.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m2s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m0s
2026-04-15 21:43:32 +02:00
Torsten Schulz (local)
15b8f3c4c1 Update version in package.json from 1.0.0 to 1.1.0 for the Harheimer Tischtennis Club website.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 3m49s
Code Analysis and Production Deploy / deploy-production (push) Failing after 0s
2026-04-15 21:36:37 +02:00
Torsten Schulz (local)
510cfd39f9 Update code-analysis workflow to include production deployment steps and rename workflow for clarity. Add SSH setup and connection testing for secure deployment to production environment.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Failing after 1s
2026-04-15 21:30:09 +02:00
Torsten Schulz (local)
e0bad51764 Update commander dependency to version 13.1.0 in package.json and package-lock.json for improved functionality and compatibility.
All checks were successful
Code Analysis (JS/Vue) / analyze (push) Successful in 3m10s
2026-04-15 21:18:26 +02:00
Torsten Schulz (local)
c1de0c1671 Enhance deploy-production.sh with error handling for git pull failures. Provide user guidance for SSH key setup and switching to HTTPS if necessary. Update code-analysis.yml to include Node.js setup with caching for improved workflow efficiency.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m25s
2026-04-15 21:09:04 +02:00
Torsten Schulz (local)
2bedbee08d Upgrade nodemailer to latest major for audit compliance.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 10s
This removes the remaining SMTP command injection advisories by moving to nodemailer 8.0.5 and refreshing the lockfile accordingly.

Made-with: Cursor
2026-04-15 21:00:43 +02:00
Torsten Schulz (local)
9c54b6907e Apply non-major audit updates and harden path handling for Semgrep.
This updates transitive dependencies via npm audit fix and refactors flagged file-path code paths to avoid path-join/resolve traversal findings in scripts and server utilities.

Made-with: Cursor
2026-04-15 21:00:28 +02:00
Torsten Schulz (local)
edfab28fd3 Add security comments to path handling in various scripts to clarify internal constant usage and mitigate path traversal risks. Update logging in registration and verification processes for improved clarity.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 2m48s
2026-04-15 20:52:38 +02:00
Torsten Schulz (local)
5f79d220cf Refactor PDF generation process in membership API to ensure consistent directory creation for uploads. Update final PDF path handling to improve clarity and maintainability of the code.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-04-15 20:50:29 +02:00
Torsten Schulz (local)
0a82b33afc Refactor PDF generation logic in membership API to improve error handling and enhance font embedding. Update LaTeX template for German language support and streamline debugging messages. Ensure encrypted data handling is consistent and improve command execution error management for PDF generation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 3m6s
2026-04-15 20:46:18 +02:00
Torsten Schulz (local)
1922e85184 Add mock implementations for role checks in auth utility and enhance sharp mock with image metadata retrieval
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-04-15 20:44:54 +02:00
Torsten Schulz (local)
0fb8052a77 Enhance deploy-test.sh script with error handling for root execution and write permissions. Add checks for successful git pull and provide user guidance for SSH key setup and repository access issues.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-04-15 20:39:59 +02:00
Torsten Schulz (local)
ef2d9353f5 Enhance ESLint configuration to include support for .mjs and .cjs file types. Update ignored files patterns to ensure proper linting of project files. Refactor Vue component templates for improved readability and maintainability, including consistent formatting and structure across various components. Update error handling in save functions to prevent silent failures.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-04-15 20:37:14 +02:00
Torsten Schulz (local)
1aae808e5f Update beitrittserklärung template PDF to the latest version.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m4s
2026-04-15 20:28:36 +02:00
Torsten Schulz (local)
75e6d66d25 Update German text for clarity in member management UI and API error messages, correcting "Fuer" to "Für" and "koennen" to "können" for improved readability.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 55s
2026-04-01 11:55:27 +02:00
Torsten Schulz (local)
daabeec33c Implement birthdate input in member profile management. Update API to handle birthdate data for user profiles and enhance visibility settings for birthday display in member lists.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m4s
2026-04-01 11:19:23 +02:00
Torsten Schulz (local)
0fb58af194 Add birthdate handling in member registration and management. Update UI to conditionally require birthdate for new members, and enhance API to enforce birthdate validation. Improve tests to cover new birthdate requirements.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-31 07:25:44 +02:00
Torsten Schulz (local)
8ffd267dfc Enhance member management UI by adding hall key status display and editing capabilities. Update API to support hall key data integration in member records.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-03-30 15:41:16 +02:00
Torsten Schulz (local)
5eee7df7e4 Refactor authentication logic in members API to use getUserFromToken for user retrieval. Update error messages for better clarity and enhance tests to reflect changes in authentication handling.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m2s
2026-03-30 15:29:25 +02:00
Torsten Schulz (local)
7dea265eef Add filter option for members with hall key in member management UI. Update member listing logic to reflect filtered results based on hall key status.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-29 15:25:27 +02:00
Torsten Schulz (local)
381ec55fd1 Refactor member acceptance logic in API to improve handling of legacy records. Remove debug logging and clarify acceptance criteria for manual members.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-29 15:00:53 +02:00
Torsten Schulz (local)
c30911daed Refactor saveMember function to enhance duplicate member handling, allowing updates to existing duplicates and improving error handling for member existence checks.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-03-29 14:54:18 +02:00
Torsten Schulz (local)
bdc9eef707 Refactor saveMember function to improve duplicate member handling and streamline member updates. Enhance error messaging for non-existent members and ensure new members are added with default active status.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-29 14:49:04 +02:00
Torsten Schulz (local)
f7701d698f Add hall key feature to member management, including UI updates for displaying and editing hall key status. Update API to handle hall key data in member records.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m6s
2026-03-29 14:37:49 +02:00
Torsten Schulz (local)
49e7255062 Enhance CSV saving functionality by adding token retrieval from authorization header if not present in cookies. Update tests to validate CSV saving for users with 'vorstand' role.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-18 13:12:32 +01:00
Torsten Schulz (local)
74246e6b08 Implement status toggle functionality for contact requests, updating the status display and adding error handling. Enhance the UI with a new button for marking requests as completed or reopening them.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-11 21:16:03 +01:00
Torsten Schulz (local)
6230c96bc9 Refactor links section to use dynamic rendering with computed properties, enhancing maintainability and scalability. Add new 'Links' tab in CMS for better navigation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-04 16:05:34 +01:00
Torsten Schulz (local)
3fb40bd87d Erweitere die Navigation um einen neuen Link zu "Links" und aktualisiere die Logik zur Bestimmung des aktuellen Submenüs, um die neue Route zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-04 14:53:11 +01:00
Torsten Schulz (local)
46c2c14ae8 Füge Unterstützung für Kontaktanfragen hinzu, einschließlich neuer Routen und Berechtigungen für Trainer und Vorstand. Aktualisiere E-Mail-Versandlogik, um Anfragen an alle relevanten Empfänger weiterzuleiten.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-26 14:28:54 +01:00
Torsten Schulz (local)
ff8c1970df Ersetze Willkommensnachricht durch Geburtstags-Widget mit dynamischer Anzeige der nächsten Geburtstage
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 16:39:52 +01:00
Torsten Schulz (local)
8347a86727 Entferne die JSON-Darstellung des Mitglieds aus der Mitgliederansicht
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-02-14 16:26:58 +01:00
Torsten Schulz (local)
9a6d32dcb3 Füge ESM-Importe und Skriptbeschreibung für das Aufteilen von Namen in Benutzer- und Bewerbungsdateien hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 16:25:29 +01:00
Torsten Schulz (local)
161618f6fb Füge Skripte zum Aufteilen von Namen in firstName und lastName für Mitglieder und Bewerbungen hinzu, einschließlich Backup-Funktionalität.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 15:58:11 +01:00
Torsten Schulz (local)
0b3fba44a4 Füge Skript zum Aufteilen von Namen in firstName und lastName für Benutzer hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 15:50:37 +01:00
Torsten Schulz (local)
d35e1c9a3e Füge Vorname und Nachname in das Registrierungsformular und die Mitgliederverwaltung ein
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 15:48:56 +01:00
Torsten Schulz (local)
528353132a Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:45:51 +01:00
Torsten Schulz (local)
cd5e5cd781 Füge die Anzeige der Mitgliederdaten im JSON-Format in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:42:58 +01:00
Torsten Schulz (local)
ebbffcc5c4 Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:38:39 +01:00
Torsten Schulz (local)
5c760d7fa8 Füge Sichtbarkeits-Flags für E-Mail und Telefon in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 03:31:28 +01:00
Torsten Schulz (local)
d40073ac7b Füge Sichtbarkeits-Flags für E-Mail, Telefon, Adresse und Geburtstag in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:23:59 +01:00
Torsten Schulz (local)
b25cf13d3c Füge Sichtbarkeits-Flags für Mitglieder hinzu, um die Anzeige von E-Mail, Telefon, Adresse und Geburtstag zu steuern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:16:28 +01:00
Torsten Schulz (local)
3287102761 Füge Vorname und Nachname zu den Mitgliederdaten hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 03:08:19 +01:00
Torsten Schulz (local)
08624cabbe Verbessere die Sichtbarkeit von Mitgliederdaten, indem das Geburtsdatum im Edit-Formular hinzugefügt wird
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:05:15 +01:00
Torsten Schulz (local)
d37f182928 Füge Skript hinzu, um Sichtbarkeitsflags für Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-14 02:58:30 +01:00
Torsten Schulz (local)
79c45be7c7 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:52:44 +01:00
Torsten Schulz (local)
d52f3ffc8d Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:50:57 +01:00
Torsten Schulz (local)
64baaf8535 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:48:30 +01:00
Torsten Schulz (local)
e665495003 Verbessere die Sortierlogik in der Mitgliederliste für Namen, Nachnamen und Geburtstage mit robusteren Vergleichen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:42:43 +01:00
Torsten Schulz (local)
8f444c59eb Füge Sortieroptionen zur Mitgliederliste hinzu und verbessere die Sortierung nach Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:36:26 +01:00
Torsten Schulz (local)
8117335af9 Entferne die Sortieroptionen und passe die Mitgliederanzeige an, um die Sortierung direkt aus der Mitgliederliste zu entfernen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-02-14 02:36:19 +01:00
Torsten Schulz (local)
85ec99b08c Optimiere das Template der Mitgliederliste durch Entfernen von überflüssigem Code und verbessere die Sortieroptionen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-02-14 02:28:40 +01:00
Torsten Schulz (local)
04571e6444 Verbessere die Struktur des Templates in der Mitgliederliste und füge Sortieroptionen hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:22:52 +01:00
Torsten Schulz (local)
5799f97570 Entferne überflüssige Zeile im Template der Mitgliederliste
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:17:27 +01:00
Torsten Schulz (local)
8ab08f4c09 Füge Header und Sortieroptionen zur Mitgliederliste hinzu
Some checks are pending
Code Analysis (JS/Vue) / analyze (push) Has started running
2026-02-14 02:16:36 +01:00
Torsten Schulz (local)
fcf3168692 Entferne überflüssige geschweifte Klammer in der formatDate-Funktion
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-14 02:13:25 +01:00
Torsten Schulz (local)
cfd209d7ee Filtere den Admin-Account aus der Mitgliederliste heraus, um die Sichtbarkeit zu verbessern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 02:07:45 +01:00
Torsten Schulz (local)
ee1709ffb2 Füge Sortieroptionen für Mitgliederliste hinzu und implementiere Sortierlogik nach Name, Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:06:36 +01:00
Torsten Schulz (local)
8bb02b6e4a Füge dotenv-Konfiguration zum Skript hinzu, um Umgebungsvariablen zu laden
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:00:39 +01:00
Torsten Schulz (local)
7a20af2772 Füge active-Feld zu Mitgliedsdaten hinzu und implementiere Skript zum Aktivieren aller Mitglieder
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 01:54:39 +01:00
Torsten Schulz (local)
3e610e68b6 Füge Debug-Logs hinzu, um alle geladenen Mitglieder (decryptet) anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 01:47:20 +01:00
Torsten Schulz (local)
485b21c13e Füge Diagnose-Skript hinzu, um Mitglieder aus members.json mit Status und Sichtbarkeit anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 01:43:18 +01:00
Torsten Schulz (local)
08b1edc354 Füge Skript zum Re-Encryptieren von Klartext-Mitgliedsanträgen hinzu; implementiere Backup-Funktion und Fehlerbehandlung
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 01:37:42 +01:00
Torsten Schulz (local)
6e297c682c Füge Geburtstags-Widget hinzu und implementiere Geburtstagsladefunktion; erweitere Sichtbarkeitseinstellungen für Geburtstage in Profil und API
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-13 17:27:27 +01:00
Torsten Schulz (local)
3d3e22bb1b Implementiere zentralen E-Mail-Service für Registrierungsbenachrichtigungen und entferne veralteten Code
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-11 15:41:03 +01:00
Torsten Schulz (local)
d18b671532 Ändere Sichtbarkeitseinstellungen für Mitglieder: Standardmäßig sichtbar für alle eingeloggten Mitglieder, es sei denn, sie sind explizit verborgen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:37:13 +01:00
Torsten Schulz (local)
af3c0164ef Füge Sichtbarkeitsoptionen für Mitglieder und registrierte Benutzer hinzu; aktualisiere die Sichtbarkeitseinstellungen basierend auf Benutzerpräferenzen in der Mitgliederabfrage und dem Sichtbarkeits-Skript.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-11 14:25:49 +01:00
Torsten Schulz (local)
c681194462 Make visibility opt-in by default; coerce visibility booleans; only 'vorstand' overrides
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:10:54 +01:00
Torsten Schulz (local)
141a15a6cb Respect per-user visibility; only 'vorstand' overrides visibility; UI shows contactHidden per-member
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:27:24 +01:00
Torsten (PC)
ce5915a3bc fixed .gitignore
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 13:08:07 +01:00
Torsten Schulz (local)
677140bd33 Füge Sichtbarkeitspräferenzen für Mitgliederprofile hinzu: Ermögliche Benutzern, ihre E-Mail, Telefonnummer und Adresse für andere eingeloggte Mitglieder sichtbar zu machen. Aktualisiere die API, um diese Einstellungen zu respektieren und bei der Profildatenrückgabe zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:04:45 +01:00
Torsten Schulz (local)
8a1e309eba Verbessere Mitgliederabfrage: Filtere manuelle Mitglieder nach aktiven/akzeptierten Status und entferne nicht benötigte Datenschutzlogik.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 44s
2026-02-11 12:49:14 +01:00
Torsten Schulz (local)
0d533710cd Refactor file handling to prioritize internal data directories for backups and uploads; enhance error handling and logging for metadata and CSV operations.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 11:42:24 +01:00
99 changed files with 7212 additions and 1857 deletions

0
.codex Normal file
View File

View File

@@ -1,17 +1,24 @@
name: Code Analysis (JS/Vue) name: Code Analysis and Production Deploy
on: on:
pull_request: pull_request:
push: push:
branches: [ main ] branches: [ main, dev ]
jobs: jobs:
analyze: analyze:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Workspace sanity check - name: Workspace sanity check
run: | run: |
echo "PWD: $(pwd)" echo "PWD: $(pwd)"
@@ -82,3 +89,63 @@ jobs:
./osv-scanner --version ./osv-scanner --version
test -f ./package-lock.json test -f ./package-lock.json
./osv-scanner --lockfile ./package-lock.json ./osv-scanner --lockfile ./package-lock.json
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run production deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run test deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

View File

@@ -0,0 +1,20 @@
name: Require Package Version Change
on:
pull_request:
branches: [ main ]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Check package.json version changed
run: scripts/check-package-version-changed.sh origin/main

4
.gitignore vendored
View File

@@ -154,3 +154,7 @@ server/data/**
!server/data/.gitkeep !server/data/.gitkeep
public/data/** public/data/**
public/uploads/** public/uploads/**
backups/*
public/data
server/data
public/uploads

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -202,7 +202,7 @@
</p> </p>
</form> </form>
<p class="mt-4 text-sm text-gray-600 text-center"> <p class="mt-4 text-sm text-gray-600 text-center">
Ihre Nachricht wird direkt an j.dichmann@gmx.de gesendet Ihre Nachricht wird an den Vorstand und die Trainer weitergeleitet
</p> </p>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,13 @@
© {{ currentYear }} Harheimer TC 1954 e.V. © {{ currentYear }} Harheimer TC 1954 e.V.
</p> </p>
<div class="flex items-center space-x-6 text-sm relative"> <div class="flex items-center space-x-6 text-sm relative">
<span
v-if="isLoggedIn && appVersion"
class="text-xs text-gray-600"
title="Version"
>
v{{ appVersion }}
</span>
<NuxtLink <NuxtLink
to="/impressum" to="/impressum"
class="text-gray-400 hover:text-primary-400 transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
@@ -89,7 +96,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { User, ChevronUp } from 'lucide-vue-next' import { User, ChevronUp } from 'lucide-vue-next'
@@ -97,11 +104,26 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const isMemberMenuOpen = ref(false) const isMemberMenuOpen = ref(false)
const appVersion = ref('')
// Reactive auth state from store // Reactive auth state from store
const isLoggedIn = computed(() => authStore.isLoggedIn) const isLoggedIn = computed(() => authStore.isLoggedIn)
// const isAdmin = computed(() => authStore.isAdmin) // const isAdmin = computed(() => authStore.isAdmin)
const loadAppVersion = async () => {
if (!isLoggedIn.value) {
appVersion.value = ''
return
}
try {
const response = await $fetch('/api/app/version')
appVersion.value = response.version || ''
} catch (_error) {
appVersion.value = ''
}
}
const toggleMemberMenu = () => { const toggleMemberMenu = () => {
isMemberMenuOpen.value = !isMemberMenuOpen.value isMemberMenuOpen.value = !isMemberMenuOpen.value
} }
@@ -116,6 +138,10 @@ onMounted(() => {
authStore.checkAuth() authStore.checkAuth()
}) })
watch(isLoggedIn, () => {
loadAppVersion()
}, { immediate: true })
// Close menu when clicking outside // Close menu when clicking outside
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (!event.target.closest('.relative')) { if (!event.target.closest('.relative')) {

View File

@@ -109,7 +109,7 @@
</a> </a>
<span class="text-sm text-gray-500">oder</span> <span class="text-sm text-gray-500">oder</span>
<NuxtLink <NuxtLink
to="/satzung" to="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors" class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
> >
<Eye <Eye

View File

@@ -109,7 +109,7 @@
</a> </a>
<span class="text-sm text-gray-500">oder</span> <span class="text-sm text-gray-500">oder</span>
<NuxtLink <NuxtLink
to="/satzung" to="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors" class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
> >
<Eye <Eye

View File

@@ -36,7 +36,7 @@
<button <button
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50" class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''" :class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || route.path.startsWith('/links') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
@click="toggleSubmenu('verein')" @click="toggleSubmenu('verein')"
> >
Verein Verein
@@ -177,6 +177,13 @@
> >
Galerie Galerie
</NuxtLink> </NuxtLink>
<NuxtLink
to="/links"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Links
</NuxtLink>
</template> </template>
<!-- Mannschaften Submenu --> <!-- Mannschaften Submenu -->
@@ -299,6 +306,16 @@
Newsletter Newsletter
</NuxtLink> </NuxtLink>
</template> </template>
<template v-if="canAccessContactRequests">
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/cms/kontaktanfragen"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Kontaktanfragen
</NuxtLink>
</template>
<template v-if="isAdmin"> <template v-if="isAdmin">
<div class="h-3 w-px bg-primary-700" /> <div class="h-3 w-px bg-primary-700" />
<div class="relative inline-block"> <div class="relative inline-block">
@@ -371,6 +388,13 @@
> >
Mitgliederverwaltung Mitgliederverwaltung
</NuxtLink> </NuxtLink>
<NuxtLink
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Kontaktanfragen
</NuxtLink>
<div class="border-t border-gray-700 my-1" /> <div class="border-t border-gray-700 my-1" />
<NuxtLink <NuxtLink
to="/cms/einstellungen" to="/cms/einstellungen"
@@ -497,6 +521,13 @@
> >
Galerie Galerie
</NuxtLink> </NuxtLink>
<NuxtLink
to="/links"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Links
</NuxtLink>
<NuxtLink <NuxtLink
to="/newsletter/subscribe" to="/newsletter/subscribe"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors" class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@@ -707,6 +738,16 @@
Newsletter Newsletter
</NuxtLink> </NuxtLink>
</template> </template>
<template v-if="canAccessContactRequests && !isAdmin">
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Kontaktanfragen
</NuxtLink>
</template>
<template v-if="isAdmin"> <template v-if="isAdmin">
<div class="border-t border-primary-700/20 my-2" /> <div class="border-t border-primary-700/20 my-2" />
<NuxtLink <NuxtLink
@@ -744,6 +785,13 @@
> >
Mitgliederverwaltung Mitgliederverwaltung
</NuxtLink> </NuxtLink>
<NuxtLink
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Kontaktanfragen
</NuxtLink>
<NuxtLink <NuxtLink
to="/cms/inhalte" to="/cms/inhalte"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors" class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@@ -825,11 +873,16 @@ const canAccessNewsletter = computed(() => {
const store = getAuthStore() const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
}) })
const canAccessContactRequests = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
})
// Automatisches Setzen des Submenus basierend auf der Route // Automatisches Setzen des Submenus basierend auf der Route
const currentSubmenu = computed(() => { const currentSubmenu = computed(() => {
const path = route.path const path = route.path
if (path.startsWith('/verein/') || path.startsWith('/vorstand') || if (path.startsWith('/verein/') || path.startsWith('/vorstand') ||
path.startsWith('/links') ||
path.startsWith('/vereinsmeisterschaften')) { path.startsWith('/vereinsmeisterschaften')) {
return 'verein' return 'verein'
} }
@@ -949,7 +1002,7 @@ const toggleSubmenu = (menu) => {
if (menu === 'newsletter' && !path.startsWith('/newsletter')) { if (menu === 'newsletter' && !path.startsWith('/newsletter')) {
navigateTo('/newsletter/subscribe') navigateTo('/newsletter/subscribe')
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften')) { } else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften') && !path.startsWith('/links')) {
navigateTo('/verein/ueber-uns') navigateTo('/verein/ueber-uns')
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) { } else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
navigateTo('/mannschaften') navigateTo('/mannschaften')

303
components/cms/CmsLinks.vue Normal file
View File

@@ -0,0 +1,303 @@
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
Links bearbeiten
</h2>
<button
type="button"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
:disabled="saving"
@click="save"
>
{{ saving ? 'Speichert...' : 'Speichern' }}
</button>
</div>
<p class="text-sm text-gray-500 mb-6">
Diese Übersicht wird auf der öffentlichen Seite als Karten dargestellt.
</p>
<div class="space-y-6">
<div
v-for="(section, sectionIndex) in sections"
:key="section.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center gap-3 mb-4">
<input
v-model="section.title"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Block-Titel"
>
<button
type="button"
class="px-3 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeSection(sectionIndex)"
>
Block löschen
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, itemIndex) in section.items"
:key="item.id"
class="grid md:grid-cols-12 gap-2"
>
<input
v-model="item.label"
type="text"
class="md:col-span-4 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Link-Text"
>
<input
v-model="item.href"
type="url"
class="md:col-span-5 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
>
<input
v-model="item.description"
type="text"
class="md:col-span-2 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Beschreibung (optional)"
>
<button
type="button"
class="md:col-span-1 px-2 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeItem(sectionIndex, itemIndex)"
>
X
</button>
</div>
</div>
<div class="mt-3">
<button
type="button"
class="px-3 py-2 text-sm bg-gray-100 text-gray-800 rounded-lg hover:bg-gray-200"
@click="addItem(sectionIndex)"
>
Link hinzufügen
</button>
</div>
</div>
</div>
<div class="mt-6">
<button
type="button"
class="px-4 py-2 text-sm bg-primary-100 text-primary-800 rounded-lg hover:bg-primary-200"
@click="addSection"
>
Block hinzufügen
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const saving = ref(false)
const sections = ref([])
function createId() {
const c = globalThis?.crypto
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const defaultSections = [
{
id: createId(),
title: 'Ergebnisse & Portale',
items: [
{ id: createId(), label: 'MyTischtennis.de', href: 'http://www.mytischtennis.de/public/home', description: '(offizielle QTTR-Werte)' },
{ id: createId(), label: 'Click-tt Ergebnisse', href: 'http://httv.click-tt.de/', description: '(offizieller Ergebnisdienst HTTV)' },
{ id: createId(), label: 'Tischtennis Pur - das Tischtennis Portal', href: 'https://www.tischtennis-pur.de/', description: '(Informationen, Blogs, Fachbeiträge, Tipps)' },
{ id: createId(), label: 'Liveticker 2. und 3. TT-Bundesliga', href: 'https://ticker.tt-news.com/', description: '' }
]
},
{
id: createId(),
title: 'Verbände',
items: [
{ id: createId(), label: 'Hessischer Tischtennisverband (HTTV)', href: 'http://www.httv.de/', description: '' },
{ id: createId(), label: 'Deutscher Tischtennisbund (DTTB)', href: 'http://www.tischtennis.de/aktuelles/', description: '' },
{ id: createId(), label: 'European Table Tennis Union (ETTU)', href: 'http://www.ettu.org/', description: '' },
{ id: createId(), label: 'International Table Tennis Federation (ITTF)', href: 'https://www.ittf.com/', description: '' }
]
},
{
id: createId(),
title: 'Regionale Links',
items: [
{ id: createId(), label: 'Stadt Frankfurt', href: 'http://www.frankfurt.de/', description: '' },
{ id: createId(), label: 'Vereinsring Harheim', href: 'http://www.harheim.com/', description: '' }
]
},
{
id: createId(),
title: 'Partner & Vereine',
items: [
{ id: createId(), label: 'TTC OE Bad Homburg', href: 'http://www.ttcoe.de/', description: '' },
{ id: createId(), label: 'SpVgg Steinkirchen e.V.', href: 'https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis', description: '' },
{ id: createId(), label: 'Ergebnisse SpVgg Steinkirchen', href: 'https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/', description: '' }
]
}
]
function createHtmlFromSections(inputSections) {
const safeSections = Array.isArray(inputSections) ? inputSections : []
return safeSections
.filter((s) => s.title && Array.isArray(s.items) && s.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item.label && item.href)
.map((item) => {
const description = item.description ? ` ${item.description}` : ''
return `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${description}</li>`
})
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections) || rawSections.length === 0) {
return JSON.parse(JSON.stringify(defaultSections))
}
return rawSections.map((section) => ({
id: section.id || createId(),
title: section.title || '',
items: Array.isArray(section.items)
? section.items.map((item) => ({
id: item.id || createId(),
label: item.label || '',
href: item.href || '',
description: item.description || ''
}))
: []
}))
}
function stripTags(html) {
if (!html) return ''
return String(html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.trim()
}
function parseLinksHtml(html) {
if (!html || typeof html !== 'string') return []
const sectionsParsed = []
const sectionPattern = /<h2[^>]*>([\s\S]*?)<\/h2>\s*<ul[^>]*>([\s\S]*?)<\/ul>/gi
let sectionMatch
while ((sectionMatch = sectionPattern.exec(html)) !== null) {
const title = stripTags(sectionMatch[1])
const ulContent = sectionMatch[2] || ''
const itemPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi
const items = []
let itemMatch
while ((itemMatch = itemPattern.exec(ulContent)) !== null) {
const liHtml = itemMatch[1] || ''
const anchorMatch = liHtml.match(/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
const href = anchorMatch ? String(anchorMatch[1]).trim() : ''
const label = anchorMatch ? stripTags(anchorMatch[2]) : stripTags(liHtml)
let description = ''
if (anchorMatch) {
description = stripTags(liHtml.replace(anchorMatch[0], ''))
}
if (label || href || description) {
items.push({
id: createId(),
label,
href,
description
})
}
}
if (title || items.length > 0) {
sectionsParsed.push({
id: createId(),
title,
items
})
}
}
return sectionsParsed
}
function addSection() {
sections.value.push({
id: createId(),
title: '',
items: [{ id: createId(), label: '', href: '', description: '' }]
})
}
function removeSection(index) {
sections.value.splice(index, 1)
}
function addItem(sectionIndex) {
sections.value[sectionIndex].items.push({
id: createId(),
label: '',
href: '',
description: ''
})
}
function removeItem(sectionIndex, itemIndex) {
sections.value[sectionIndex].items.splice(itemIndex, 1)
}
async function load() {
try {
const current = await $fetch('/api/config')
const configured = current?.seiten?.linksStructured
if (Array.isArray(configured) && configured.length > 0) {
sections.value = normalizeSections(configured)
return
}
const html = current?.seiten?.links
const parsed = parseLinksHtml(html)
sections.value = normalizeSections(parsed)
} catch {
sections.value = JSON.parse(JSON.stringify(defaultSections))
}
}
async function save() {
saving.value = true
try {
const current = await $fetch('/api/config')
const cleanedSections = normalizeSections(sections.value)
const linksHtml = createHtmlFromSections(cleanedSections)
const updated = {
...current,
seiten: {
...(current?.seiten || {}),
linksStructured: cleanedSections,
links: linksHtml
}
}
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch (_e) { /* no-op */ }
} catch (error) {
const msg = error?.data?.message || 'Fehler beim Speichern der Links'
try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch (_e) { /* no-op */ }
} finally {
saving.value = false
}
}
onMounted(load)
</script>

View File

@@ -2,39 +2,98 @@
<div> <div>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2> <h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
Mannschaften verwalten
</h2>
<div class="w-24 h-1 bg-primary-600" /> <div class="w-24 h-1 bg-primary-600" />
</div> </div>
<button class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal"> <button
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<Plus
:size="20"
class="mr-2"
/> Mannschaft hinzufügen
</button> </button>
</div> </div>
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div> <div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden"> <div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th> Mannschaft
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th> Liga
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Staffelleiter
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaftsführer
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Spieler
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50"> <tr
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td> v-for="(mannschaft, index) in mannschaften"
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td> :key="index"
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td> class="hover:bg-gray-50"
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td> >
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td> <td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ mannschaft.mannschaft }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.liga }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.staffelleiter }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.mannschaftsfuehrer }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
<div class="max-w-xs truncate">
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3"> <td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button> <button
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button> class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(mannschaft, index)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(mannschaft, index)"
>
<Trash2 :size="18" />
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -42,86 +101,248 @@
</div> </div>
</div> </div>
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center"> <div
<Users :size="48" class="text-gray-400 mx-auto mb-4" /> v-if="!isLoading && mannschaften.length === 0"
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3> class="bg-white rounded-xl shadow-lg p-12 text-center"
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p> >
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button> <Users
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine Mannschaften vorhanden
</h3>
<p class="text-gray-600 mb-6">
Fügen Sie die erste Mannschaft hinzu.
</p>
<button
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
Mannschaft hinzufügen
</button>
</div> </div>
<!-- Add/Edit Modal --> <!-- Add/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal"> <div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200"> <div class="p-6 border-b border-gray-200">
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2> <h2 class="text-2xl font-display font-bold text-gray-900">
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
</h2>
</div> </div>
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft"> <form
class="p-6 space-y-4"
@submit.prevent="saveMannschaft"
>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
<input v-model="formData.mannschaft" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.mannschaft"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
<input v-model="formData.liga" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.liga"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label> <label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
<input v-model="formData.staffelleiter" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.staffelleiter"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label> <label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
<input v-model="formData.telefon" type="tel" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.telefon"
type="tel"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label> <label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
<input v-model="formData.heimspieltag" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.heimspieltag"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label> <label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
<input v-model="formData.spielsystem" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.spielsystem"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label> <label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
<input v-model="formData.mannschaftsfuehrer" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.mannschaftsfuehrer"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label> <label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
<div class="space-y-2"> <div class="space-y-2">
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div> <div
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white"> v-if="formData.spielerListe.length === 0"
class="text-sm text-gray-500"
>
Noch keine Spieler eingetragen.
</div>
<div
v-for="(spieler, index) in formData.spielerListe"
:key="spieler.id"
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
>
<div class="flex flex-col lg:flex-row lg:items-center gap-2"> <div class="flex flex-col lg:flex-row lg:items-center gap-2">
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving"> <input
v-model="spieler.name"
type="text"
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Spielername"
:disabled="isSaving"
>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button> <button
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button> type="button"
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button> class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach oben"
:disabled="isSaving || index === 0"
@click="moveSpielerUp(index)"
>
<ChevronUp :size="18" />
</button>
<button
type="button"
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach unten"
:disabled="isSaving || index === formData.spielerListe.length - 1"
@click="moveSpielerDown(index)"
>
<ChevronDown :size="18" />
</button>
<button
type="button"
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Spieler entfernen"
:disabled="isSaving"
@click="removeSpieler(spieler.id)"
>
<Trash2 :size="18" />
</button>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen"> <select
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option> v-model="moveTargetBySpielerId[spieler.id]"
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
title="Mannschaft auswählen"
>
<option
v-for="t in mannschaftenSelectOptions"
:key="t"
:value="t"
>
{{ t }}
</option>
</select> </select>
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button> <button
type="button"
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
title="In ausgewählte Mannschaft verschieben"
@click="moveSpielerToMannschaft(spieler.id)"
>
<ArrowRight :size="18" />
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-3 flex items-center justify-between"> <div class="mt-3 flex items-center justify-between">
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button> <button
<p class="text-xs text-gray-500">Reihenfolge per / ändern.</p> type="button"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
:disabled="isSaving"
@click="addSpieler()"
>
<Plus
:size="18"
class="mr-2"
/> Spieler hinzufügen
</button>
<p class="text-xs text-gray-500">
Reihenfolge per / ändern.
</p>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label> <label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving"> <input
v-model="formData.weitere_informationen_link"
type="url"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
:disabled="isSaving"
>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/> {{ errorMessage }}
</div> </div>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"><AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}</div>
<div class="flex justify-end space-x-4 pt-4"> <div class="flex justify-end space-x-4 pt-4">
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button> <button
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"><Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span></button> type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -53,6 +53,17 @@
</div> </div>
</div> </div>
<div class="mb-4 flex items-center">
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
v-model="filterHasHallKey"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
Nur mit Hallenschlüssel
</label>
</div>
<!-- Loading State --> <!-- Loading State -->
<div <div
v-if="isLoading" v-if="isLoading"
@@ -85,6 +96,9 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft Mannschaft
</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
🔑
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status Status
</th> </th>
@@ -98,7 +112,7 @@
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr <tr
v-for="member in members" v-for="member in filteredMembers"
:key="member.id" :key="member.id"
class="hover:bg-gray-50" class="hover:bg-gray-50"
> >
@@ -177,6 +191,15 @@
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }} {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span> </span>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="member.hasHallKey"
class="text-lg text-amber-600"
title="Hat Hallenschlüssel"
>
🔑
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
@@ -227,7 +250,7 @@
</div> </div>
<div <div
v-if="members.length === 0" v-if="filteredMembers.length === 0"
class="text-center py-12 text-gray-500" class="text-center py-12 text-gray-500"
> >
Keine Mitglieder gefunden. Keine Mitglieder gefunden.
@@ -240,7 +263,7 @@
class="space-y-4" class="space-y-4"
> >
<div <div
v-for="member in members" v-for="member in filteredMembers"
:key="member.id" :key="member.id"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
> >
@@ -250,6 +273,13 @@
<h3 class="text-xl font-semibold text-gray-900"> <h3 class="text-xl font-semibold text-gray-900">
{{ member.name }} {{ member.name }}
</h3> </h3>
<span
v-if="member.hasHallKey"
class="ml-2 text-amber-600"
title="Hat Hallenschlüssel"
>
🔑
</span>
<span <span
v-if="member.hasLogin" v-if="member.hasLogin"
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full" class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
@@ -389,7 +419,7 @@
</div> </div>
<div <div
v-if="members.length === 0" v-if="filteredMembers.length === 0"
class="text-center py-12 text-gray-500" class="text-center py-12 text-gray-500"
> >
Keine Mitglieder gefunden. Keine Mitglieder gefunden.
@@ -439,12 +469,12 @@
<input <input
v-model="formData.geburtsdatum" v-model="formData.geburtsdatum"
type="date" type="date"
required :required="isBirthdateRequired"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving" :disabled="isSaving"
> >
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Wird zur eindeutigen Identifizierung benötigt Für neue Mitglieder erforderlich. Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
</p> </p>
</div> </div>
@@ -504,6 +534,22 @@
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="hasHallKey"
v-model="formData.hasHallKey"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving"
>
<label
for="hasHallKey"
class="ml-2 block text-sm font-medium text-gray-700"
>
Hat Hallenschlüssel
</label>
</div>
<div <div
v-if="errorMessage" v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
@@ -624,10 +670,18 @@
<table class="min-w-full divide-y divide-gray-200 text-sm"> <table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0"> <thead class="bg-gray-50 sticky top-0">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Vorname</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th> Vorname
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th> </th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Nachname
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Geburtsdatum
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">
E-Mail
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@@ -636,10 +690,18 @@
:key="index" :key="index"
class="hover:bg-gray-50" class="hover:bg-gray-50"
> >
<td class="px-3 py-2">{{ row.firstName || '-' }}</td> <td class="px-3 py-2">
<td class="px-3 py-2">{{ row.lastName || '-' }}</td> {{ row.firstName || '-' }}
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td> </td>
<td class="px-3 py-2">{{ row.email || '-' }}</td> <td class="px-3 py-2">
{{ row.lastName || '-' }}
</td>
<td class="px-3 py-2">
{{ row.geburtsdatum || '-' }}
</td>
<td class="px-3 py-2">
{{ row.email || '-' }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -658,35 +720,65 @@
class="mb-6" class="mb-6"
> >
<div class="bg-gray-50 rounded-lg p-4"> <div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3> <h3 class="text-lg font-semibold text-gray-900 mb-3">
Import-Ergebnisse
</h3>
<div class="grid grid-cols-3 gap-4 mb-4"> <div class="grid grid-cols-3 gap-4 mb-4">
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div> <div class="text-2xl font-bold text-green-600">
<div class="text-sm text-gray-600">Importiert</div> {{ bulkImportResults.summary.imported }}
</div>
<div class="text-sm text-gray-600">
Importiert
</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div> <div class="text-2xl font-bold text-yellow-600">
<div class="text-sm text-gray-600">Duplikate</div> {{ bulkImportResults.summary.duplicates }}
</div>
<div class="text-sm text-gray-600">
Duplikate
</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div> <div class="text-2xl font-bold text-red-600">
<div class="text-sm text-gray-600">Fehler</div> {{ bulkImportResults.summary.errors }}
</div>
<div class="text-sm text-gray-600">
Fehler
</div>
</div> </div>
</div> </div>
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4"> <div
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4> v-if="bulkImportResults.results.duplicates.length > 0"
class="mt-4"
>
<h4 class="text-sm font-medium text-gray-700 mb-2">
Duplikate:
</h4>
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto"> <div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="dup in bulkImportResults.results.duplicates" :key="dup.index"> <div
v-for="dup in bulkImportResults.results.duplicates"
:key="dup.index"
>
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }} Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
</div> </div>
</div> </div>
</div> </div>
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4"> <div
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4> v-if="bulkImportResults.results.errors.length > 0"
class="mt-4"
>
<h4 class="text-sm font-medium text-gray-700 mb-2">
Fehler:
</h4>
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto"> <div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="err in bulkImportResults.results.errors" :key="err.index"> <div
v-for="err in bulkImportResults.results.errors"
:key="err.index"
>
Zeile {{ err.index }}: {{ err.error }} Zeile {{ err.index }}: {{ err.error }}
</div> </div>
</div> </div>
@@ -734,6 +826,7 @@ const showModal = ref(false)
const editingMember = ref(null) const editingMember = ref(null)
const errorMessage = ref('') const errorMessage = ref('')
const viewMode = ref('cards') const viewMode = ref('cards')
const filterHasHallKey = ref(false)
// Bulk import state // Bulk import state
const showBulkImportModal = ref(false) const showBulkImportModal = ref(false)
@@ -752,7 +845,8 @@ const formData = ref({
phone: '', phone: '',
address: '', address: '',
notes: '', notes: '',
isMannschaftsspieler: false isMannschaftsspieler: false,
hasHallKey: false
}) })
const canEdit = computed(() => { const canEdit = computed(() => {
@@ -763,6 +857,15 @@ const canViewContactData = computed(() => {
return authStore.hasRole('vorstand') return authStore.hasRole('vorstand')
}) })
const isBirthdateRequired = computed(() => {
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
})
const filteredMembers = computed(() => {
if (!filterHasHallKey.value) return members.value
return members.value.filter(member => member.hasHallKey)
})
const loadMembers = async () => { const loadMembers = async () => {
isLoading.value = true isLoading.value = true
try { try {
@@ -777,7 +880,7 @@ const loadMembers = async () => {
const openAddModal = () => { const openAddModal = () => {
editingMember.value = null editingMember.value = null
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false } formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''
} }
@@ -792,7 +895,8 @@ const openEditModal = (member) => {
phone: member.phone || '', phone: member.phone || '',
address: member.address || '', address: member.address || '',
notes: member.notes || '', notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true isMannschaftsspieler: member.isMannschaftsspieler === true,
hasHallKey: member.hasHallKey === true
} }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''

View File

@@ -105,15 +105,23 @@
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4> <h4 class="text-sm font-medium text-gray-900 mb-2">
Kontaktdaten
</h4>
<div class="space-y-1 text-sm text-gray-600"> <div class="space-y-1 text-sm text-gray-600">
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p> <p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
<p v-if="application.personalData.telefon_privat"><strong>Telefon:</strong> {{ application.personalData.telefon_privat }}</p> <p v-if="application.personalData.telefon_privat">
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p> <strong>Telefon:</strong> {{ application.personalData.telefon_privat }}
</p>
<p v-if="application.personalData.telefon_mobil">
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
</p>
</div> </div>
</div> </div>
<div> <div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4> <h4 class="text-sm font-medium text-gray-900 mb-2">
Antragsdetails
</h4>
<div class="space-y-1 text-sm text-gray-600"> <div class="space-y-1 text-sm text-gray-600">
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p> <p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p> <p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
@@ -141,8 +149,18 @@
class="text-gray-400 hover:text-gray-600" class="text-gray-400 hover:text-gray-600"
@click="closeModal" @click="closeModal"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -151,16 +169,24 @@
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Persönliche Daten
</h3>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p> <p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p> <p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
<p v-if="selectedApplication.personalData.telefon_privat"><strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}</p> <p v-if="selectedApplication.personalData.telefon_privat">
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p> <strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}
</p>
<p v-if="selectedApplication.personalData.telefon_mobil">
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
</p>
</div> </div>
</div> </div>
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Antragsdetails
</h3>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p> <p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p> <p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
@@ -172,14 +198,29 @@
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeModal">Schließen</button> <button
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeModal"
>
Schließen
</button>
<button <button
v-if="selectedApplication.metadata.pdfGenerated" v-if="selectedApplication.metadata.pdfGenerated"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
@click="downloadPDF(selectedApplication.id)" @click="downloadPDF(selectedApplication.id)"
> >
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
PDF herunterladen PDF herunterladen
</button> </button>

View File

@@ -2,112 +2,335 @@
<div> <div>
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2> <h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
Spielpläne bearbeiten
</h2>
<div class="space-x-3"> <div class="space-x-3">
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true"> <button
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg> class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
@click="showUploadModal = true"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/></svg>
CSV hochladen CSV hochladen
</button> </button>
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button> <button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div> </div>
</div> </div>
<!-- CSV Upload Section --> <!-- CSV Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6"> <div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3> <h3 class="text-xl font-semibold text-gray-900 mb-4">
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"> Vereins-Spielplan (CSV)
</h3>
<div
v-if="currentFile"
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div> class="w-5 h-5 text-green-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg>
<div>
<p class="text-sm font-medium text-green-800">
{{ currentFile.name }}
</p><p class="text-xs text-green-600">
{{ currentFile.size }} bytes
</p>
</div>
</div> </div>
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button> <button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="removeFile"
>
Entfernen
</button>
</div> </div>
</div> </div>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer" :class="{ 'border-primary-400 bg-primary-50': isDragOver }" @click="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop"> <div
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg> class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p> :class="{ 'border-primary-400 bg-primary-50': isDragOver }"
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p> @click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/></svg>
<p class="text-lg font-medium text-gray-900 mb-2">
CSV-Datei hochladen
</p>
<p class="text-sm text-gray-600 mb-4">
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
</p>
</div> </div>
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect"> <input
ref="fileInput"
type="file"
accept=".csv"
class="hidden"
@change="handleFileSelect"
>
</div> </div>
<!-- Column Selection --> <!-- Column Selection -->
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8"> <div
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3> v-if="csvData.length > 0 && !columnsSelected"
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p> class="bg-white rounded-xl shadow-lg p-6 mb-8"
>
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Spalten auswählen
</h3>
<p class="text-sm text-gray-600 mb-6">
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
</p>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"> <div
v-for="(header, index) in csvHeaders"
:key="index"
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center"> <div class="flex items-center">
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"> <input
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label> :id="`column-${index}`"
v-model="selectedColumns[index]"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<label
:for="`column-${index}`"
class="ml-3 text-sm font-medium text-gray-900"
>{{ header }}</label>
</div>
<div class="text-xs text-gray-500">
{{ getColumnPreview(index) }}
</div> </div>
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
</div> </div>
</div> </div>
<div class="mt-6 flex justify-between items-center"> <div class="mt-6 flex justify-between items-center">
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div> <div class="text-sm text-gray-600">
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
</div>
<div class="space-x-3"> <div class="space-x-3">
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button> <button
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button> class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button> @click="selectAllColumns"
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button> >
Alle auswählen
</button>
<button
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="deselectAllColumns"
>
Alle abwählen
</button>
<button
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="suggestHalleColumns"
>
Halle-Spalten vorschlagen
</button>
<button
:disabled="selectedColumnsCount === 0"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="confirmColumnSelection"
>
Auswahl bestätigen
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Data Preview --> <!-- Data Preview -->
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6"> <div
v-if="csvData.length > 0 && columnsSelected"
class="bg-white rounded-xl shadow-lg p-6"
>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3> <h3 class="text-xl font-semibold text-gray-900">
Datenvorschau
</h3>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button> <button
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button> class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="exportCSV"
>
CSV exportieren
</button>
<button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="clearData"
>
Daten löschen
</button>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead> <thead class="bg-gray-50">
<tr>
<th
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
:key="index"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ header }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"> <tr
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td> v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
{{ cell }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div> <div
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div> v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
class="mt-4 text-center text-sm text-gray-600"
>
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
</div>
<div class="mt-4 text-sm text-gray-600">
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
</div>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg"> <div
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> v-if="csvData.length === 0"
<p class="text-gray-600">Keine CSV-Daten geladen.</p> class="text-center py-12 bg-white rounded-xl shadow-lg"
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p> >
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/></svg>
<p class="text-gray-600">
Keine CSV-Daten geladen.
</p>
<p class="text-sm text-gray-500 mt-2">
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
</p>
</div> </div>
<!-- Upload Modal --> <!-- Upload Modal -->
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal"> <div
v-if="showUploadModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeUploadModal"
>
<div class="bg-white rounded-lg max-w-md w-full p-6"> <div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">
CSV-Datei hochladen
</h3>
<div class="space-y-4"> <div class="space-y-4">
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div> <div>
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div> <label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input
<div class="bg-blue-50 p-4 rounded-lg"><h4 class="text-sm font-medium text-blue-800 mb-2">Erwartetes CSV-Format:</h4><div class="text-xs text-blue-700 space-y-1"><p> Erste Zeile: Spaltenüberschriften</p><p> Trennzeichen: Komma (,)</p></div></div> ref="modalFileInput"
type="file"
accept=".csv"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@change="handleModalFileSelect"
>
</div>
<div
v-if="selectedFile"
class="p-3 bg-gray-50 rounded-lg"
>
<p class="text-sm text-gray-700">
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
</p><p class="text-xs text-gray-500">
{{ selectedFile.size }} bytes
</p>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-blue-800 mb-2">
Erwartetes CSV-Format:
</h4><div class="text-xs text-blue-700 space-y-1">
<p> Erste Zeile: Spaltenüberschriften</p><p> Trennzeichen: Komma (,)</p>
</div>
</div>
</div> </div>
<div class="flex justify-end space-x-3 pt-4"> <div class="flex justify-end space-x-3 pt-4">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button> <button
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button> class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeUploadModal"
>
Abbrechen
</button>
<button
:disabled="!selectedFile"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="processSelectedFile"
>
Hochladen
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Processing Modal --> <!-- Processing Modal -->
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div
v-if="isProcessing"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center"> <div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" /> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3> <h3 class="text-lg font-semibold text-gray-900 mb-2">
<p class="text-sm text-gray-600">{{ processingMessage }}</p> Verarbeitung läuft...
</h3>
<p class="text-sm text-gray-600">
{{ processingMessage }}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,36 +11,72 @@
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal" @click="openAddModal"
> >
<Plus :size="20" class="mr-2" /> <Plus
:size="20"
class="mr-2"
/>
Termin hinzufügen Termin hinzufügen
</button> </button>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12"> <div
<Loader2 :size="40" class="animate-spin text-primary-600" /> v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div> </div>
<!-- Termine Table --> <!-- Termine Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden"> <div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th> Datum
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th> Uhrzeit
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Titel
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Beschreibung
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kategorie
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50"> <tr
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td> v-for="termin in termine"
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td> :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td> class="hover:bg-gray-50"
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td> >
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(termin.datum) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ termin.uhrzeit || '-' }}
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ termin.titel }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ termin.beschreibung || '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<span <span
:class="{ :class="{
@@ -54,56 +90,140 @@
>{{ termin.kategorie }}</span> >{{ termin.kategorie }}</span>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3"> <td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button> <button
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button> class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(termin)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(termin)"
>
<Trash2 :size="18" />
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div> <div
v-if="termine.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Termine vorhanden.
</div>
</div> </div>
<!-- Add/Edit Modal --> <!-- Add/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal"> <div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8"> <div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2> <h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
<form class="space-y-4" @submit.prevent="saveTermin"> {{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
</h2>
<form
class="space-y-4"
@submit.prevent="saveTermin"
>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
<input v-model="formData.datum" type="date" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.datum"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label> <label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
<input v-model="formData.uhrzeit" type="time" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.uhrzeit"
type="time"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<select v-model="formData.kategorie" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <select
<option value="Training">Training</option> v-model="formData.kategorie"
<option value="Punktspiel">Punktspiel</option> required
<option value="Turnier">Turnier</option> class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
<option value="Veranstaltung">Veranstaltung</option> :disabled="isSaving"
<option value="Sonstiges">Sonstiges</option> >
<option value="Training">
Training
</option>
<option value="Punktspiel">
Punktspiel
</option>
<option value="Turnier">
Turnier
</option>
<option value="Veranstaltung">
Veranstaltung
</option>
<option value="Sonstiges">
Sonstiges
</option>
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
<input v-model="formData.titel" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving"> <input
v-model="formData.titel"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label> <label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea v-model="formData.beschreibung" rows="3" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving" /> <textarea
v-model="formData.beschreibung"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
/>
</div> </div>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"> <div
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }} v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/> {{ errorMessage }}
</div> </div>
<div class="flex justify-end space-x-4 pt-4"> <div class="flex justify-end space-x-4 pt-4">
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button> <button
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"> type="button"
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /> class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span> <span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button> </button>
</div> </div>

View File

@@ -18,36 +18,108 @@
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3"> <div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
<!-- Formatierung --> <!-- Formatierung -->
<div class="flex items-center gap-1 border-r pr-2 mr-2"> <div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button> class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button> @click="format('bold')"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button> >
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button> <strong>B</strong>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('italic')"
>
<em>I</em>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(1)"
>
H1
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(2)"
>
H2
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(3)"
>
H3
</button>
</div> </div>
<!-- Listen --> <!-- Listen -->
<div class="flex items-center gap-1 border-r pr-2 mr-2"> <div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')"></button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button> class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
</div> </div>
<!-- Schnellzugriff für Regeln --> <!-- Schnellzugriff für Regeln -->
<div class="flex items-center gap-1 border-r pr-2 mr-2"> <div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button> class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button> @click="insertRuleTemplate('generic')"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button> >
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button> Neue Regel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
@click="insertRuleTemplate('basic')"
>
Grundregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
@click="insertRuleTemplate('penalty')"
>
Strafregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
@click="insertRuleTemplate('service')"
>
Aufschlag
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
@click="deleteCurrentRule()"
>
Regel löschen
</button>
</div> </div>
<!-- Weitere Tools --> <!-- Weitere Tools -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button> class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="createLink()"
>
Link
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="removeFormat()"
>
Clear
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Hilfe-Sektion --> <!-- Hilfe-Sektion -->
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4"> <div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3> <h3 class="text-lg font-semibold text-blue-900 mb-2">
So arbeiten Sie mit Regel-Kästchen:
</h3>
<div class="text-sm text-blue-800 space-y-2"> <div class="text-sm text-blue-800 space-y-2">
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p> <p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
<ul class="ml-4 space-y-1"> <ul class="ml-4 space-y-1">

View File

@@ -54,6 +54,108 @@ has_tracked_files_under() {
git ls-files "$prefix" | head -n 1 | grep -q . git ls-files "$prefix" | head -n 1 | grep -q .
} }
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
fi
}
install_dependencies_if_needed() {
local cache_dir=".deploy-cache"
local lock_hash_file="$cache_dir/package-lock.sha256"
local current_lock_hash=""
local previous_lock_hash=""
if [ ! -f "package-lock.json" ]; then
echo " package-lock.json fehlt, führe npm install aus..."
install_dependencies
return 0
fi
mkdir -p "$cache_dir"
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
if [ -f "$lock_hash_file" ]; then
previous_lock_hash="$(cat "$lock_hash_file" 2>/dev/null || true)"
fi
if [ ! -d "node_modules" ]; then
echo " node_modules fehlt, installiere Dependencies..."
install_dependencies
elif [ "$current_lock_hash" != "$previous_lock_hash" ]; then
echo " package-lock.json geändert, führe npm ci aus..."
install_dependencies
else
echo " package-lock.json unverändert, überspringe npm ci"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh"
if [ -f ".nvmrc" ]; then
echo " Using Node version from .nvmrc..."
nvm use
fi
fi
}
ensure_node_version() {
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js ist nicht im PATH."
exit 1
fi
local node_version
node_version="$(node -p 'process.versions.node')"
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
echo " Node.js $node_version"
}
sync_public_documents_to_build() {
if [ ! -d "public/documents" ]; then
echo " No public/documents directory to sync"
return 0
fi
if [ ! -d ".output/public" ]; then
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
exit 1
fi
mkdir -p ".output/public/documents"
cp -a "public/documents/." ".output/public/documents/"
echo " ✓ public/documents -> .output/public/documents synchronisiert"
local template_pdf="beitrittserklärung_template.pdf"
if [ -f "public/documents/$template_pdf" ]; then
local source_size output_size
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
exit 1
fi
echo "$template_pdf im Build verifiziert ($output_size bytes)"
fi
}
echo "0. Ensuring persistent data directories (recommended)..." echo "0. Ensuring persistent data directories (recommended)..."
# IMPORTANT: Only symlink server/data if it's not tracked by git. # IMPORTANT: Only symlink server/data if it's not tracked by git.
if has_tracked_files_under "server/data"; then if has_tracked_files_under "server/data"; then
@@ -100,6 +202,18 @@ if ls public/data/*.csv >/dev/null 2>&1; then
else else
echo " No public CSVs to backup (public/data/*.csv not found)" echo " No public CSVs to backup (public/data/*.csv not found)"
fi fi
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
elif ls public/data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
else
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
fi
# 2. Handle local changes and Git Pull # 2. Handle local changes and Git Pull
echo "2. Handling local changes and pulling latest from git..." echo "2. Handling local changes and pulling latest from git..."
@@ -125,7 +239,20 @@ git clean -fd \
# Pull latest changes # Pull latest changes
echo " Pulling latest changes..." echo " Pulling latest changes..."
git pull git fetch origin main
git checkout -B main origin/main
if ! git reset --hard origin/main; then
echo "ERROR: git pull fehlgeschlagen."
echo ""
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
echo "Prüfen:"
echo " ssh -T git@tsschulz.de"
echo ""
echo "Optional auf HTTPS wechseln:"
echo " git remote set-url origin https://tsschulz.de/<owner>/<repo>.git"
echo "Oder SSH-Key für User $(id -un) hinterlegen."
exit 1
fi
# Reset any accidental changes from stash restore (should be none now) # Reset any accidental changes from stash restore (should be none now)
git reset --hard HEAD >/dev/null 2>&1 git reset --hard HEAD >/dev/null 2>&1
@@ -141,7 +268,9 @@ fi
# 3. Install dependencies # 3. Install dependencies
echo "" echo ""
echo "3. Installing dependencies..." echo "3. Installing dependencies..."
npm install use_project_node
ensure_node_version
install_dependencies_if_needed
# 4. Remove old build (but keep data!) # 4. Remove old build (but keep data!)
echo "" echo ""
@@ -164,18 +293,23 @@ if [ -d ".output" ]; then
echo " ✓ .output gelöscht" echo " ✓ .output gelöscht"
fi fi
# Auch .nuxt Cache löschen für sauberen Build # .nuxt standardmäßig behalten (beschleunigt Folge-Builds deutlich).
if [ -d ".nuxt" ]; then # Für erzwungenen Clean-Build: CLEAN_NUXT_CACHE=1 ./deploy-production.sh
echo " Removing .nuxt cache..." if [ "${CLEAN_NUXT_CACHE:-0}" = "1" ]; then
rm -rf .nuxt if [ -d ".nuxt" ]; then
echo " ✓ .nuxt gelöscht" echo " CLEAN_NUXT_CACHE=1 gesetzt: entferne .nuxt cache..."
rm -rf .nuxt
echo " ✓ .nuxt gelöscht"
fi
else
echo " Behalte .nuxt cache für schnelleren Build (CLEAN_NUXT_CACHE=1 für Clean-Build)"
fi fi
# Prüfe, ob node_modules vorhanden ist (für npm run build) # Prüfe, ob node_modules vorhanden ist (für npm run build)
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ]; then
echo "" echo ""
echo "WARNING: node_modules fehlt. Installiere Dependencies..." echo "WARNING: node_modules fehlt. Installiere Dependencies..."
npm install install_dependencies
fi fi
# 5. Build # 5. Build
@@ -188,28 +322,36 @@ echo " (This may take a few minutes...)"
echo " Checking dependencies..." echo " Checking dependencies..."
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
echo " WARNING: package-lock.json fehlt. Führe npm install aus..." echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install install_dependencies
fi fi
# Build mit expliziter Fehlerbehandlung und Output-Capture # Build mit expliziter Fehlerbehandlung und gleichzeitiger Log-Datei
BUILD_OUTPUT=$(npm run build 2>&1) BUILD_LOG_FILE=".deploy-cache/build-$(date +%Y%m%d-%H%M%S).log"
BUILD_EXIT_CODE=$? mkdir -p ".deploy-cache"
if npm run build 2>&1 | tee "$BUILD_LOG_FILE"; then
# Zeige Build-Output BUILD_EXIT_CODE=0
echo "$BUILD_OUTPUT" else
BUILD_EXIT_CODE=$?
fi
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
echo "" echo ""
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE" echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler." echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
echo "Build-Log: $BUILD_LOG_FILE"
exit 1 exit 1
fi fi
echo ""
echo " Synchronizing public documents into build output..."
sync_public_documents_to_build
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen # Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then if rg -i "error|failed|missing" "$BUILD_LOG_FILE" >/dev/null 2>&1; then
echo "" echo ""
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen." echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
echo "Bitte prüfen Sie die Ausgabe oben." echo "Bitte prüfen Sie die Ausgabe oben."
echo "Build-Log: $BUILD_LOG_FILE"
fi fi
# Prüfe, ob der Build erfolgreich war - mehrere Checks # Prüfe, ob der Build erfolgreich war - mehrere Checks
@@ -443,4 +585,3 @@ echo " pm2 status # View status"
echo " pm2 restart harheimertc # Restart instance on port 3100" echo " pm2 restart harheimertc # Restart instance on port 3100"
echo " pm2 restart harheimertc-3102 # Restart instance on port 3102" echo " pm2 restart harheimertc-3102 # Restart instance on port 3102"
echo " pm2 restart all # Restart all instances" echo " pm2 restart all # Restart all instances"

View File

@@ -13,6 +13,14 @@ echo ""
echo "Working directory: $(pwd)" echo "Working directory: $(pwd)"
echo "" echo ""
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
echo "ERROR: Dieses Script darf nicht mit sudo/root ausgeführt werden."
echo "Grund: HOME=/root würde DATA/BACKUP unter /root anlegen und Git-SSH des normalen Users umgehen."
echo "Bitte so starten:"
echo " ./deploy-test.sh"
exit 1
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "ERROR: Dieses Script muss im Git-Repository ausgeführt werden (kein .git gefunden)." echo "ERROR: Dieses Script muss im Git-Repository ausgeführt werden (kein .git gefunden)."
exit 1 exit 1
@@ -37,6 +45,12 @@ ensure_symlink_dir() {
fi fi
if [ -d "$src" ]; then if [ -d "$src" ]; then
if [ ! -w "$src" ]; then
echo "ERROR: Keine Schreibrechte auf $src"
echo "Bitte Besitz korrigieren, dann erneut starten:"
echo " sudo chown -R $(id -un):$(id -gn) \"$src\""
exit 1
fi
echo " Moving $src -> $target (first-time migration)" echo " Moving $src -> $target (first-time migration)"
# Merge existing content into target # Merge existing content into target
cp -a "$src/." "$target/" || true cp -a "$src/." "$target/" || true
@@ -53,6 +67,108 @@ has_tracked_files_under() {
git ls-files "$prefix" | head -n 1 | grep -q . git ls-files "$prefix" | head -n 1 | grep -q .
} }
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
fi
}
install_dependencies_if_needed() {
local cache_dir=".deploy-cache"
local lock_hash_file="$cache_dir/package-lock.sha256"
local current_lock_hash=""
local previous_lock_hash=""
if [ ! -f "package-lock.json" ]; then
echo " package-lock.json fehlt, führe npm install aus..."
install_dependencies
return 0
fi
mkdir -p "$cache_dir"
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
if [ -f "$lock_hash_file" ]; then
previous_lock_hash="$(cat "$lock_hash_file" 2>/dev/null || true)"
fi
if [ ! -d "node_modules" ]; then
echo " node_modules fehlt, installiere Dependencies..."
install_dependencies
elif [ "$current_lock_hash" != "$previous_lock_hash" ]; then
echo " package-lock.json geändert, führe npm ci aus..."
install_dependencies
else
echo " package-lock.json unverändert, überspringe npm ci"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh"
if [ -f ".nvmrc" ]; then
echo " Using Node version from .nvmrc..."
nvm use
fi
fi
}
ensure_node_version() {
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js ist nicht im PATH."
exit 1
fi
local node_version
node_version="$(node -p 'process.versions.node')"
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
echo " Node.js $node_version"
}
sync_public_documents_to_build() {
if [ ! -d "public/documents" ]; then
echo " No public/documents directory to sync"
return 0
fi
if [ ! -d ".output/public" ]; then
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
exit 1
fi
mkdir -p ".output/public/documents"
cp -a "public/documents/." ".output/public/documents/"
echo " ✓ public/documents -> .output/public/documents synchronisiert"
local template_pdf="beitrittserklärung_template.pdf"
if [ -f "public/documents/$template_pdf" ]; then
local source_size output_size
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
exit 1
fi
echo "$template_pdf im Build verifiziert ($output_size bytes)"
fi
}
echo "0. Ensuring persistent data directories (recommended)..." echo "0. Ensuring persistent data directories (recommended)..."
# IMPORTANT: Only symlink server/data if it's not tracked by git. # IMPORTANT: Only symlink server/data if it's not tracked by git.
if has_tracked_files_under "server/data"; then if has_tracked_files_under "server/data"; then
@@ -92,12 +208,17 @@ else
exit 1 exit 1
fi fi
if ls public/data/*.csv >/dev/null 2>&1; then # Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
elif ls public/data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data" mkdir -p "$BACKUP_DIR/public-data"
cp -a public/data/*.csv "$BACKUP_DIR/public-data/" cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/" echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
else else
echo " No public CSVs to backup (public/data/*.csv not found)" echo " No public CSVs to backup (server/data/public-data or public/data not found)"
fi fi
# 2. Handle local changes and Git Pull # 2. Handle local changes and Git Pull
@@ -124,7 +245,20 @@ git clean -fd \
# Pull latest changes # Pull latest changes
echo " Pulling latest changes..." echo " Pulling latest changes..."
git pull git fetch origin dev
git checkout -B dev origin/dev
if ! git reset --hard origin/dev; then
echo "ERROR: git pull fehlgeschlagen."
echo ""
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
echo "Prüfen:"
echo " ssh -T git@tsschulz.de"
echo ""
echo "Optional auf HTTPS wechseln:"
echo " git remote set-url origin https://tsschulz.de/<owner>/<repo>.git"
echo "Oder SSH-Key für User $(id -un) hinterlegen."
exit 1
fi
# Reset any accidental changes from stash restore (should be none now) # Reset any accidental changes from stash restore (should be none now)
git reset --hard HEAD >/dev/null 2>&1 git reset --hard HEAD >/dev/null 2>&1
@@ -140,7 +274,9 @@ fi
# 3. Install dependencies # 3. Install dependencies
echo "" echo ""
echo "3. Installing dependencies..." echo "3. Installing dependencies..."
npm install use_project_node
ensure_node_version
install_dependencies_if_needed
# 4. Remove old build (but keep data!) # 4. Remove old build (but keep data!)
echo "" echo ""
@@ -163,18 +299,23 @@ if [ -d ".output" ]; then
echo " ✓ .output gelöscht" echo " ✓ .output gelöscht"
fi fi
# Auch .nuxt Cache löschen für sauberen Build # .nuxt standardmäßig behalten (beschleunigt Folge-Builds deutlich).
if [ -d ".nuxt" ]; then # Für erzwungenen Clean-Build: CLEAN_NUXT_CACHE=1 ./deploy-test.sh
echo " Removing .nuxt cache..." if [ "${CLEAN_NUXT_CACHE:-0}" = "1" ]; then
rm -rf .nuxt if [ -d ".nuxt" ]; then
echo " ✓ .nuxt gelöscht" echo " CLEAN_NUXT_CACHE=1 gesetzt: entferne .nuxt cache..."
rm -rf .nuxt
echo " ✓ .nuxt gelöscht"
fi
else
echo " Behalte .nuxt cache für schnelleren Build (CLEAN_NUXT_CACHE=1 für Clean-Build)"
fi fi
# Prüfe, ob node_modules vorhanden ist (für npm run build) # Prüfe, ob node_modules vorhanden ist (für npm run build)
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ]; then
echo "" echo ""
echo "WARNING: node_modules fehlt. Installiere Dependencies..." echo "WARNING: node_modules fehlt. Installiere Dependencies..."
npm install install_dependencies
fi fi
# 5. Build # 5. Build
@@ -187,28 +328,36 @@ echo " (This may take a few minutes...)"
echo " Checking dependencies..." echo " Checking dependencies..."
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
echo " WARNING: package-lock.json fehlt. Führe npm install aus..." echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install install_dependencies
fi fi
# Build mit expliziter Fehlerbehandlung und Output-Capture # Build mit expliziter Fehlerbehandlung und gleichzeitiger Log-Datei
BUILD_OUTPUT=$(npm run build 2>&1) BUILD_LOG_FILE=".deploy-cache/build-$(date +%Y%m%d-%H%M%S).log"
BUILD_EXIT_CODE=$? mkdir -p ".deploy-cache"
if npm run build 2>&1 | tee "$BUILD_LOG_FILE"; then
# Zeige Build-Output BUILD_EXIT_CODE=0
echo "$BUILD_OUTPUT" else
BUILD_EXIT_CODE=$?
fi
if [ "$BUILD_EXIT_CODE" -ne 0 ]; then if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
echo "" echo ""
echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE" echo "ERROR: Build fehlgeschlagen mit Exit-Code $BUILD_EXIT_CODE"
echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler." echo "Bitte prüfen Sie die Build-Ausgabe oben auf Fehler."
echo "Build-Log: $BUILD_LOG_FILE"
exit 1 exit 1
fi fi
echo ""
echo " Synchronizing public documents into build output..."
sync_public_documents_to_build
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen # Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then if rg -i "error|failed|missing" "$BUILD_LOG_FILE" >/dev/null 2>&1; then
echo "" echo ""
echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen." echo "WARNING: Build-Output enthält möglicherweise Fehler oder Warnungen."
echo "Bitte prüfen Sie die Ausgabe oben." echo "Bitte prüfen Sie die Ausgabe oben."
echo "Build-Log: $BUILD_LOG_FILE"
fi fi
# Prüfe, ob der Build erfolgreich war - mehrere Checks # Prüfe, ob der Build erfolgreich war - mehrere Checks
@@ -310,38 +459,33 @@ echo " Restored server/data from backup ($BACKUP_DIR/server-data)."
# Stelle alle CSVs wieder her # Stelle alle CSVs wieder her
if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then
mkdir -p public/data # Restore into internal storage (server/data/public-data)
mkdir -p server/data/public-data
# WICHTIG: Überschreibe auch Dateien, die aus dem Git-Repository kommen
# Verwende cp mit -f (force) um sicherzustellen, dass Backup-Dateien Vorrang haben
for csv_file in "$BACKUP_DIR/public-data"/*.csv; do for csv_file in "$BACKUP_DIR/public-data"/*.csv; do
filename=$(basename "$csv_file") filename=$(basename "$csv_file")
# Überschreibe explizit, auch wenn Datei bereits existiert cp -f "$csv_file" "server/data/public-data/$filename"
cp -f "$csv_file" "public/data/$filename" if [ -f "server/data/public-data/$filename" ]; then
# Stelle sicher, dass die Datei wirklich überschrieben wurde
if [ -f "public/data/$filename" ]; then
# Prüfe, ob die Datei wirklich vom Backup kommt (Größenvergleich)
backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0") backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0")
restored_size=$(stat -f%z "public/data/$filename" 2>/dev/null || stat -c%s "public/data/$filename" 2>/dev/null || echo "0") restored_size=$(stat -f%z "server/data/public-data/$filename" 2>/dev/null || stat -c%s "server/data/public-data/$filename" 2>/dev/null || echo "0")
if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then
echo " Restored public/data/$filename from backup ($backup_size bytes)" echo " \u2713 Restored server/data/public-data/$filename from backup ($backup_size bytes)"
else else
echo " WARNING: public/data/$filename Größe stimmt nicht überein (Backup: $backup_size, Restored: $restored_size)" echo " \u26a0 WARNING: server/data/public-data/$filename size mismatch (Backup: $backup_size, Restored: $restored_size)"
fi fi
else else
echo " ERROR: Konnte public/data/$filename nicht wiederherstellen!" echo " \u274c ERROR: Konnte server/data/public-data/$filename nicht wiederherstellen!"
fi fi
done done
echo " All public/data/*.csv files restored from backup ($BACKUP_DIR/public-data)." echo " \u2713 All public-data files restored into server/data/public-data ($BACKUP_DIR/public-data)."
# Zusätzliche Sicherheit: Entferne public/data Dateien aus Git-Index, falls sie getrackt sind # Optional: synchronize internal public-data into public/data for legacy builds
# (nach dem Restore, damit sie nicht beim nächsten git reset überschrieben werden) # This uses the project's sync script and forces overwrite in public/data.
if git ls-files --error-unmatch public/data/*.csv >/dev/null 2>&1; then if command -v node >/dev/null 2>&1 && [ -f scripts/sync-public-data.js ]; then
echo " WARNING: public/data/*.csv Dateien sind noch im Git getrackt!" echo " Synchronizing server/data/public-data -> public/data (using scripts/sync-public-data.js --force)"
echo " Entferne sie aus dem Git-Index (Dateien bleiben erhalten)..." node scripts/sync-public-data.js --force || echo " WARNING: sync script failed"
git rm --cached public/data/*.csv 2>/dev/null || true else
echo " ✓ public/data/*.csv aus Git-Index entfernt" echo " Note: To publish CSVs to public/data run: node scripts/sync-public-data.js --force"
fi fi
else else
echo " No public CSVs to restore" echo " No public CSVs to restore"

View File

@@ -7,7 +7,7 @@ export default [
js.configs.recommended, js.configs.recommended,
...vue.configs['flat/recommended'], ...vue.configs['flat/recommended'],
{ {
files: ['**/*.vue', '**/*.js'], files: ['**/*.vue', '**/*.js', '**/*.mjs', '**/*.cjs'],
languageOptions: { languageOptions: {
parser: parser, parser: parser,
ecmaVersion: 'latest', ecmaVersion: 'latest',
@@ -81,6 +81,8 @@ export default [
'node_modules/**', 'node_modules/**',
'.output/**', '.output/**',
'.nuxt/**', '.nuxt/**',
'**/.output/**',
'**/.nuxt/**',
'.next/**', '.next/**',
'dist/**', 'dist/**',
'build/**', 'build/**',
@@ -88,6 +90,7 @@ export default [
'*.config.ts', '*.config.ts',
'*.config.cjs', '*.config.cjs',
'*.cjs', '*.cjs',
'**/*.cjs',
'temp/**', 'temp/**',
'backups/**', 'backups/**',
'public/**', 'public/**',

View File

@@ -10,7 +10,8 @@ try {
// Helper function to create env object // Helper function to create env object
function createEnv(port) { function createEnv(port) {
return { return {
NODE_ENV: 'production', NODE_ENV: process.env.NODE_ENV || 'development',
APP_ENV: process.env.APP_ENV || 'test',
PORT: port, PORT: port,
// Secrets/Config (loaded from .env above, if present) // Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,

View File

@@ -19,12 +19,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
if (to.path.startsWith('/cms')) { if (to.path.startsWith('/cms')) {
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : []) const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter') const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
const canAccessContactRequests = roles.includes('admin') || roles.includes('vorstand') || roles.includes('trainer')
// Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand // Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand
if (to.path.startsWith('/cms/newsletter')) { if (to.path.startsWith('/cms/newsletter')) {
if (!hasAccess) { if (!hasAccess) {
return navigateTo('/mitgliederbereich') return navigateTo('/mitgliederbereich')
} }
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
if (!canAccessContactRequests) {
return navigateTo('/mitgliederbereich')
}
} else { } else {
// Andere CMS-Seiten nur für Admin oder Vorstand // Andere CMS-Seiten nur für Admin oder Vorstand
const isAdmin = roles.includes('admin') || roles.includes('vorstand') const isAdmin = roles.includes('admin') || roles.includes('vorstand')

View File

@@ -1,12 +1,19 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: process.env.NODE_ENV !== 'production' },
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'], modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
nitro: { nitro: {
preset: 'node-server', preset: 'node-server',
dev: process.env.NODE_ENV !== 'production' dev: process.env.NODE_ENV !== 'production',
sourceMap: false
},
vite: {
build: {
reportCompressedSize: false
}
}, },
// Erzwinge Dev-Port und Host zuverlässig für `npm run dev` // Erzwinge Dev-Port und Host zuverlässig für `npm run dev`

3195
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.0.0", "version": "1.1.6",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=22.12.0",
"npm": ">=10"
},
"scripts": { "scripts": {
"dev": "nuxt dev --port 3100", "dev": "nuxt dev --port 3100",
"build": "nuxt build", "build": "nuxt build",
@@ -12,6 +16,9 @@
"start": "nuxt start --port 3100", "start": "nuxt start --port 3100",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest run", "test": "vitest run",
"check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js",
"test:watch": "vitest watch", "test:watch": "vitest watch",
"lint": "eslint . --fix" "lint": "eslint . --fix"
}, },
@@ -24,7 +31,7 @@
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"nodemailer": "^7.0.9", "nodemailer": "^8.0.5",
"nuxt": "^4.1.3", "nuxt": "^4.1.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
@@ -38,11 +45,12 @@
"@nuxtjs/tailwindcss": "^6.11.0", "@nuxtjs/tailwindcss": "^6.11.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"commander": "^13.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint-plugin-vue": "^10.6.2", "eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0", "globals": "^16.5.0",
"lucide-vue-next": "^0.344.0", "lucide-vue-next": "^0.344.0",
"postcss": "^8.4.0", "postcss": "^8.5.12",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vitest": "^4.0.16", "vitest": "^4.0.16",

View File

@@ -70,6 +70,9 @@
<option value="newsletter"> <option value="newsletter">
Newsletter Newsletter
</option> </option>
<option value="trainer">
Trainer
</option>
</select> </select>
<!-- Approve Button --> <!-- Approve Button -->
@@ -103,9 +106,31 @@
<!-- Active Users --> <!-- Active Users -->
<div> <div>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4"> <div class="flex flex-col gap-3 mb-4 sm:flex-row sm:items-end sm:justify-between">
Aktive Benutzer ({{ activeUsers.length }}) <h2 class="text-2xl font-display font-bold text-gray-900">
</h2> Aktive Benutzer ({{ sortedActiveUsers.length }})
</h2>
<div class="flex items-center gap-2">
<label
for="user-sort-order"
class="text-sm font-medium text-gray-700"
>
Sortierung
</label>
<select
id="user-sort-order"
v-model="nameSortMode"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-600"
>
<option value="firstLast">
Vorname Nachname
</option>
<option value="lastFirst">
Nachname, Vorname
</option>
</select>
</div>
</div>
<div class="bg-white rounded-xl shadow-lg overflow-hidden"> <div class="bg-white rounded-xl shadow-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
@@ -132,13 +157,13 @@
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr <tr
v-for="user in activeUsers" v-for="user in sortedActiveUsers"
:key="user.id" :key="user.id"
class="hover:bg-gray-50" class="hover:bg-gray-50"
> >
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900"> <div class="text-sm font-medium text-gray-900">
{{ user.name }} {{ getDisplayName(user) }}
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
@@ -177,10 +202,11 @@
'bg-red-100 text-red-800': role === 'admin', 'bg-red-100 text-red-800': role === 'admin',
'bg-blue-100 text-blue-800': role === 'vorstand', 'bg-blue-100 text-blue-800': role === 'vorstand',
'bg-green-100 text-green-800': role === 'newsletter', 'bg-green-100 text-green-800': role === 'newsletter',
'bg-amber-100 text-amber-800': role === 'trainer',
'bg-gray-100 text-gray-800': role === 'mitglied' 'bg-gray-100 text-gray-800': role === 'mitglied'
}" }"
> >
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : 'Mitglied' }} {{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : role === 'trainer' ? 'Trainer' : 'Mitglied' }}
</span> </span>
</div> </div>
<button <button
@@ -249,7 +275,7 @@
> >
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6"> <div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4"> <h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Rollen bearbeiten: {{ editingUser.name }} Rollen bearbeiten: {{ getDisplayName(editingUser) }}
</h2> </h2>
<div class="space-y-3 mb-6"> <div class="space-y-3 mb-6">
@@ -280,6 +306,15 @@
> >
<span class="ml-2 text-sm text-gray-700">Newsletter</span> <span class="ml-2 text-sm text-gray-700">Newsletter</span>
</label> </label>
<label class="flex items-center">
<input
v-model="selectedRoles"
type="checkbox"
value="trainer"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<span class="ml-2 text-sm text-gray-700">Trainer</span>
</label>
<label class="flex items-center"> <label class="flex items-center">
<input <input
v-model="selectedRoles" v-model="selectedRoles"
@@ -337,6 +372,7 @@ const errorMessage = ref('')
const showRoleModal = ref(false) const showRoleModal = ref(false)
const editingUser = ref(null) const editingUser = ref(null)
const selectedRoles = ref([]) const selectedRoles = ref([])
const nameSortMode = ref('firstLast')
const pendingUsers = computed(() => { const pendingUsers = computed(() => {
return allUsers.value return allUsers.value
@@ -351,6 +387,61 @@ const activeUsers = computed(() => {
return allUsers.value.filter(u => u.active === true) return allUsers.value.filter(u => u.active === true)
}) })
const splitNameParts = (name = '') => {
const trimmed = (name || '').trim()
if (!trimmed) {
return { firstName: '', lastName: '' }
}
if (trimmed.includes(',')) {
const [lastNameRaw, ...firstNameRaw] = trimmed.split(',')
return {
firstName: firstNameRaw.join(',').trim(),
lastName: (lastNameRaw || '').trim()
}
}
const parts = trimmed.split(/\s+/).filter(Boolean)
if (parts.length <= 1) {
return { firstName: parts[0] || '', lastName: '' }
}
return {
firstName: parts[0],
lastName: parts.slice(1).join(' ')
}
}
const getDisplayName = (user) => {
const { firstName, lastName } = splitNameParts(user?.name || '')
if (nameSortMode.value === 'lastFirst') {
if (!lastName) {
return firstName
}
return `${lastName}, ${firstName}`.trim()
}
return `${firstName} ${lastName}`.trim()
}
const sortedActiveUsers = computed(() => {
return [...activeUsers.value].sort((a, b) => {
const nameA = splitNameParts(a.name)
const nameB = splitNameParts(b.name)
if (nameSortMode.value === 'lastFirst') {
const lastNameCompare = nameA.lastName.localeCompare(nameB.lastName, 'de', { sensitivity: 'base' })
if (lastNameCompare !== 0) return lastNameCompare
return nameA.firstName.localeCompare(nameB.firstName, 'de', { sensitivity: 'base' })
}
const firstNameCompare = nameA.firstName.localeCompare(nameB.firstName, 'de', { sensitivity: 'base' })
if (firstNameCompare !== 0) return firstNameCompare
return nameA.lastName.localeCompare(nameB.lastName, 'de', { sensitivity: 'base' })
})
})
const formatDate = (dateString) => { const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('de-DE', { return new Date(dateString).toLocaleString('de-DE', {
year: 'numeric', year: 'numeric',

View File

@@ -324,6 +324,14 @@
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
> >
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
<input
v-model="trainer.email"
type="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Zusatzinfo</label> <label class="block text-sm font-medium text-gray-700 mb-2">Zusatzinfo</label>
<div class="flex space-x-2"> <div class="flex space-x-2">
@@ -668,6 +676,7 @@ const addTrainer = () => {
name: '', name: '',
lizenz: '', lizenz: '',
schwerpunkt: '', schwerpunkt: '',
email: '',
zusatz: '' zusatz: ''
}) })
} }

View File

@@ -7,6 +7,54 @@
<div class="w-24 h-1 bg-primary-600 mb-8" /> <div class="w-24 h-1 bg-primary-600 mb-8" />
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Geburtstage Widget -->
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
<Calendar
:size="20"
class="text-pink-600"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Geburtstage (nächste 4 Wochen)
</h2>
</div>
<div
v-if="loadingBirthdays"
class="text-sm text-gray-500"
>
Lade...
</div>
<ul
v-else
class="space-y-2"
>
<li
v-for="b in birthdays"
:key="b.name + b.dayMonth"
class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"
>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">
{{ b.name }}
</div>
<div class="text-xs text-gray-600">
{{ b.dayMonth }}
</div>
</div>
<div class="text-sm text-gray-500">
{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}
</div>
</li>
<li
v-if="birthdays.length === 0"
class="text-sm text-gray-600"
>
Keine Geburtstage in den nächsten 4 Wochen.
</li>
</ul>
</div>
<!-- Inhalte (gruppiert) --> <!-- Inhalte (gruppiert) -->
<NuxtLink <NuxtLink
to="/cms/inhalte" to="/cms/inhalte"
@@ -90,6 +138,27 @@
</p> </p>
</NuxtLink> </NuxtLink>
<!-- Kontaktanfragen -->
<NuxtLink
to="/cms/kontaktanfragen"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center group-hover:bg-emerald-600 transition-colors">
<Mail
:size="24"
class="text-emerald-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Kontaktanfragen
</h2>
</div>
<p class="text-gray-600">
Kontaktformular-Anfragen einsehen und beantworten
</p>
</NuxtLink>
<!-- Startseite --> <!-- Startseite -->
<NuxtLink <NuxtLink
to="/cms/startseite" to="/cms/startseite"
@@ -159,10 +228,31 @@
</template> </template>
<script setup> <script setup>
import { Newspaper, Calendar, Users, UserCog, Settings, Layout } from 'lucide-vue-next' import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail } from 'lucide-vue-next'
import { ref, onMounted } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const birthdays = ref([])
const loadingBirthdays = ref(true)
const loadBirthdays = async () => {
loadingBirthdays.value = true
try {
const res = await $fetch('/api/birthdays')
birthdays.value = res.birthdays || []
} catch (e) {
console.error('Fehler beim Laden der Geburtstage', e)
birthdays.value = []
} finally {
loadingBirthdays.value = false
}
}
onMounted(() => {
loadBirthdays()
})
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
layout: 'default' layout: 'default'

View File

@@ -2,13 +2,20 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Inhalte verwalten</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-1 text-sm text-gray-500">Redaktionelle Inhalte der Website bearbeiten</p> Inhalte verwalten
</h1>
<p class="mt-1 text-sm text-gray-500">
Redaktionelle Inhalte der Website bearbeiten
</p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200 mb-6"> <div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs"> <nav
class="-mb-px flex space-x-8 overflow-x-auto"
aria-label="Tabs"
>
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"
@@ -29,6 +36,7 @@
<CmsGeschichte v-if="activeTab === 'geschichte'" /> <CmsGeschichte v-if="activeTab === 'geschichte'" />
<CmsTtRegeln v-if="activeTab === 'tt-regeln'" /> <CmsTtRegeln v-if="activeTab === 'tt-regeln'" />
<CmsSatzung v-if="activeTab === 'satzung'" /> <CmsSatzung v-if="activeTab === 'satzung'" />
<CmsLinks v-if="activeTab === 'links'" />
</div> </div>
</div> </div>
</div> </div>
@@ -40,6 +48,7 @@ import CmsUeberUns from '~/components/cms/CmsUeberUns.vue'
import CmsGeschichte from '~/components/cms/CmsGeschichte.vue' import CmsGeschichte from '~/components/cms/CmsGeschichte.vue'
import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue' import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue'
import CmsSatzung from '~/components/cms/CmsSatzung.vue' import CmsSatzung from '~/components/cms/CmsSatzung.vue'
import CmsLinks from '~/components/cms/CmsLinks.vue'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
@@ -56,6 +65,7 @@ const tabs = [
{ id: 'ueber-uns', label: 'Über uns' }, { id: 'ueber-uns', label: 'Über uns' },
{ id: 'geschichte', label: 'Geschichte' }, { id: 'geschichte', label: 'Geschichte' },
{ id: 'tt-regeln', label: 'TT-Regeln' }, { id: 'tt-regeln', label: 'TT-Regeln' },
{ id: 'satzung', label: 'Satzung' } { id: 'satzung', label: 'Satzung' },
{ id: 'links', label: 'Links' }
] ]
</script> </script>

View File

@@ -0,0 +1,270 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-4xl font-display font-bold text-gray-900">
Kontaktanfragen
</h1>
<div class="w-24 h-1 bg-primary-600 mt-4" />
</div>
<button
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
:disabled="isLoading"
@click="loadRequests"
>
{{ isLoading ? 'Lädt...' : 'Aktualisieren' }}
</button>
</div>
<div class="mb-4 flex items-center justify-end">
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
v-model="showAnswered"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
>
Bearbeitete Anfragen anzeigen
</label>
</div>
<div
v-if="isLoading"
class="text-center py-12 text-gray-600"
>
Lade Kontaktanfragen...
</div>
<div
v-else-if="filteredRequests.length === 0"
class="bg-white rounded-xl shadow p-8 text-center text-gray-600"
>
{{ showAnswered ? 'Aktuell liegen keine Kontaktanfragen vor.' : 'Aktuell liegen keine offenen Kontaktanfragen vor.' }}
</div>
<div
v-else
class="space-y-4"
>
<div
v-for="request in filteredRequests"
:key="request.id"
class="bg-white rounded-xl shadow border border-gray-100"
>
<div class="p-5 border-b border-gray-100 flex items-start justify-between gap-4">
<div>
<p class="text-lg font-semibold text-gray-900">
{{ request.subject }}
</p>
<p class="text-sm text-gray-600">
Von {{ request.name }} ({{ request.email }}){{ request.phone ? ` · ${request.phone}` : '' }}
</p>
<p class="text-xs text-gray-500 mt-1">
Eingegangen: {{ formatDate(request.createdAt) }}
</p>
</div>
<span
class="px-2.5 py-1 rounded-full text-xs font-semibold"
:class="request.status === 'beantwortet' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
>
{{ request.status === 'beantwortet' ? 'Erledigt' : 'Offen' }}
</span>
</div>
<div class="p-5">
<p class="text-gray-800 whitespace-pre-wrap">
{{ request.message }}
</p>
<div
v-if="Array.isArray(request.replies) && request.replies.length > 0"
class="mt-5 border-t border-gray-100 pt-4"
>
<h3 class="text-sm font-semibold text-gray-700 mb-2">
Antworten
</h3>
<div class="space-y-2">
<div
v-for="reply in request.replies"
:key="reply.id"
class="bg-gray-50 rounded-lg p-3"
>
<p class="text-xs text-gray-500 mb-1">
{{ formatDate(reply.createdAt) }}{{ reply.responderEmail ? ` · ${reply.responderEmail}` : '' }}
</p>
<p class="text-sm text-gray-800 whitespace-pre-wrap">
{{ reply.message }}
</p>
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
:disabled="togglingId === request.id"
@click="toggleStatus(request)"
>
{{ togglingId === request.id ? '…' : (request.status === 'beantwortet' ? 'Wieder öffnen' : 'Als erledigt markieren') }}
</button>
<button
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
@click="openReplyModal(request)"
>
Antworten
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="replyModalOpen && selectedRequest"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeReplyModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-2">
Antwort senden
</h2>
<p class="text-sm text-gray-600 mb-4">
An: {{ selectedRequest.email }}<br>
Betreff: <strong>Aw: {{ selectedRequest.subject }}</strong>
</p>
<textarea
v-model="replyText"
rows="8"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600"
placeholder="Ihre Antwort..."
/>
<div
v-if="errorMessage"
class="mt-3 text-sm text-red-600"
>
{{ errorMessage }}
</div>
<div class="mt-5 flex justify-end gap-3">
<button
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
:disabled="isSendingReply"
@click="closeReplyModal"
>
Abbrechen
</button>
<button
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
:disabled="isSendingReply || !replyText.trim()"
@click="sendReply"
>
{{ isSendingReply ? 'Sende...' : 'Antwort senden' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const requests = ref([])
const isLoading = ref(false)
const replyModalOpen = ref(false)
const selectedRequest = ref(null)
const replyText = ref('')
const isSendingReply = ref(false)
const errorMessage = ref('')
const showAnswered = ref(false)
const togglingId = ref(null)
const filteredRequests = computed(() => {
if (showAnswered.value) return requests.value
return requests.value.filter((request) => request.status !== 'beantwortet')
})
const formatDate = (value) => {
if (!value) return '-'
return new Date(value).toLocaleString('de-DE')
}
const loadRequests = async () => {
isLoading.value = true
try {
requests.value = await $fetch('/api/cms/contact-requests')
} catch (error) {
console.error('Fehler beim Laden der Kontaktanfragen:', error)
requests.value = []
} finally {
isLoading.value = false
}
}
const openReplyModal = (request) => {
selectedRequest.value = request
replyText.value = ''
errorMessage.value = ''
replyModalOpen.value = true
}
const closeReplyModal = () => {
replyModalOpen.value = false
selectedRequest.value = null
replyText.value = ''
errorMessage.value = ''
}
const toggleStatus = async (request) => {
togglingId.value = request.id
try {
await $fetch(`/api/cms/contact-requests/${request.id}/toggle-status`, {
method: 'PATCH'
})
await loadRequests()
} catch (error) {
console.error('Fehler beim Umschalten des Status:', error)
if (window.showErrorModal) {
window.showErrorModal('Fehler', error?.data?.statusMessage || 'Status konnte nicht geändert werden.')
}
} finally {
togglingId.value = null
}
}
const sendReply = async () => {
if (!selectedRequest.value) return
const text = replyText.value.trim()
if (!text) return
isSendingReply.value = true
errorMessage.value = ''
try {
await $fetch(`/api/cms/contact-requests/${selectedRequest.value.id}/reply`, {
method: 'POST',
body: { message: text }
})
closeReplyModal()
await loadRequests()
if (window.showSuccessModal) {
window.showSuccessModal('Erfolg', 'Antwort wurde erfolgreich versendet.')
}
} catch (error) {
console.error('Fehler beim Senden der Antwort:', error)
errorMessage.value = error?.data?.statusMessage || error?.data?.message || 'Antwort konnte nicht gesendet werden.'
} finally {
isSendingReply.value = false
}
}
onMounted(loadRequests)
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Kontaktanfragen - CMS - Harheimer TC'
})
</script>

View File

@@ -2,15 +2,25 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Mitgliederverwaltung</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-1 text-sm text-gray-500">Anträge und Mitgliederliste verwalten</p> Mitgliederverwaltung
</h1>
<p class="mt-1 text-sm text-gray-500">
Anträge und Mitgliederliste verwalten
</p>
</div> </div>
<!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) --> <!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) -->
<div v-show="antraegeRef?.hasApplications" class="mb-10"> <div
v-show="antraegeRef?.hasApplications"
class="mb-10"
>
<CmsMitgliedschaftsantraege ref="antraegeRef" /> <CmsMitgliedschaftsantraege ref="antraegeRef" />
</div> </div>
<div v-if="antraegeRef?.hasApplications" class="border-t border-gray-300 mb-10" /> <div
v-if="antraegeRef?.hasApplications"
class="border-t border-gray-300 mb-10"
/>
<!-- Mitgliederliste darunter --> <!-- Mitgliederliste darunter -->
<CmsMitglieder /> <CmsMitglieder />

View File

@@ -2,13 +2,20 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Sportbetrieb verwalten</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-1 text-sm text-gray-500">Termine, Mannschaften und Spielpläne pflegen</p> Sportbetrieb verwalten
</h1>
<p class="mt-1 text-sm text-gray-500">
Termine, Mannschaften und Spielpläne pflegen
</p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200 mb-6"> <div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs"> <nav
class="-mb-px flex space-x-8 overflow-x-auto"
aria-label="Tabs"
>
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"

View File

@@ -58,7 +58,10 @@
> >
<!-- Drag Handle --> <!-- Drag Handle -->
<div class="flex flex-col gap-1 cursor-move"> <div class="flex flex-col gap-1 cursor-move">
<GripVertical :size="16" class="text-gray-400" /> <GripVertical
:size="16"
class="text-gray-400"
/>
</div> </div>
<!-- Section Info --> <!-- Section Info -->

162
pages/links.vue Normal file
View File

@@ -0,0 +1,162 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Links
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<p class="text-lg text-gray-600 mb-10">
Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.
</p>
<div class="grid md:grid-cols-2 gap-6">
<section
v-for="section in sections"
:key="section.title"
class="bg-white rounded-xl shadow-lg p-6"
>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
{{ section.title }}
</h2>
<ul class="space-y-3">
<li
v-for="(item, idx) in section.items"
:key="`${section.title}-${idx}`"
>
<a
:href="item.href"
target="_blank"
rel="noopener noreferrer"
class="text-primary-700 hover:text-primary-900 font-medium"
>
{{ item.label }}
</a>
<span
v-if="item.description"
class="text-gray-600"
> {{ item.description }}</span>
</li>
</ul>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const rawContent = ref('')
const defaultLinksHtml = `
<h2>Ergebnisse &amp; Portale</h2>
<ul>
<li><a href="http://www.mytischtennis.de/public/home" target="_blank" rel="noopener noreferrer">MyTischtennis.de</a> (offizielle QTTR-Werte)</li>
<li><a href="http://httv.click-tt.de/" target="_blank" rel="noopener noreferrer">Click-tt Ergebnisse</a> (offizieller Ergebnisdienst HTTV)</li>
<li><a href="https://www.tischtennis-pur.de/" target="_blank" rel="noopener noreferrer">Tischtennis Pur - das Tischtennis Portal</a> (Informationen, Blogs, Fachbeiträge, Tipps)</li>
<li><a href="https://ticker.tt-news.com/" target="_blank" rel="noopener noreferrer">Liveticker 2. und 3. TT-Bundesliga</a></li>
</ul>
<h2>Verbände</h2>
<ul>
<li><a href="http://www.httv.de/" target="_blank" rel="noopener noreferrer">Hessischer Tischtennisverband (HTTV)</a></li>
<li><a href="http://www.tischtennis.de/aktuelles/" target="_blank" rel="noopener noreferrer">Deutscher Tischtennisbund (DTTB)</a></li>
<li><a href="http://www.ettu.org/" target="_blank" rel="noopener noreferrer">European Table Tennis Union (ETTU)</a></li>
<li><a href="https://www.ittf.com/" target="_blank" rel="noopener noreferrer">International Table Tennis Federation (ITTF)</a></li>
</ul>
<h2>Regionale Links</h2>
<ul>
<li><a href="http://www.frankfurt.de/" target="_blank" rel="noopener noreferrer">Stadt Frankfurt</a></li>
<li><a href="http://www.harheim.com/" target="_blank" rel="noopener noreferrer">Vereinsring Harheim</a></li>
</ul>
<h2>Partner &amp; Vereine</h2>
<ul>
<li><a href="http://www.ttcoe.de/" target="_blank" rel="noopener noreferrer">TTC OE Bad Homburg</a></li>
<li><a href="https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis" target="_blank" rel="noopener noreferrer">SpVgg Steinkirchen e.V.</a></li>
<li><a href="https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/" target="_blank" rel="noopener noreferrer">Ergebnisse SpVgg Steinkirchen</a></li>
</ul>
`
const sections = computed(() => parseLinksHtml(rawContent.value))
function stripTags(html) {
return String(html || '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function parseLinksHtml(html) {
const source = String(html || '')
const sectionRegex = /<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*>|$)/gi
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi
const anchorRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i
const parsed = []
let sectionMatch
while ((sectionMatch = sectionRegex.exec(source)) !== null) {
const title = stripTags(sectionMatch[1])
const body = sectionMatch[2]
const items = []
let liMatch
while ((liMatch = liRegex.exec(body)) !== null) {
const liContent = liMatch[1]
const anchorMatch = anchorRegex.exec(liContent)
if (!anchorMatch) continue
const href = anchorMatch[1].trim()
const label = stripTags(anchorMatch[2])
const remainder = liContent.replace(anchorMatch[0], '')
const desc = stripTags(remainder)
items.push({
href,
label,
description: desc || ''
})
}
if (title && items.length > 0) {
parsed.push({ title, items })
}
}
return parsed
}
async function loadConfig() {
try {
const data = await $fetch('/api/config')
const structured = data?.seiten?.linksStructured
if (Array.isArray(structured) && structured.length > 0) {
const htmlFromStructured = structured
.filter((section) => section?.title && Array.isArray(section?.items) && section.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item?.label && item?.href)
.map((item) => `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${item.description ? ` ${item.description}` : ''}</li>`)
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
rawContent.value = htmlFromStructured || defaultLinksHtml
return
}
const links = data?.seiten?.links
rawContent.value = typeof links === 'string' && links.trim() ? links : defaultLinksHtml
} catch {
rawContent.value = defaultLinksHtml
}
}
onMounted(loadConfig)
useHead({
title: 'Links - Harheimer TC',
})
</script>

View File

@@ -154,7 +154,9 @@ const handleLogin = async () => {
// Redirect based on role // Redirect based on role
setTimeout(() => { setTimeout(() => {
const roles = response.user.roles || (response.user.role ? [response.user.role] : []) const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) { if (roles.includes('trainer')) {
router.push('/cms/kontaktanfragen')
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
router.push('/cms') router.push('/cms')
} else { } else {
router.push('/mitgliederbereich') router.push('/mitgliederbereich')

View File

@@ -223,7 +223,7 @@
<div class="mt-3 bg-blue-50 rounded-lg p-3"> <div class="mt-3 bg-blue-50 rounded-lg p-3">
<p class="text-xs text-blue-800"> <p class="text-xs text-blue-800">
<strong>Hinweis:</strong> Ohne <code>id</code> wird ein neues Mitglied erstellt. Mit <code>id</code> wird ein bestehendes Mitglied aktualisiert. <code>geburtsdatum</code> ist Pflichtfeld zur Duplikatsprüfung (Format: YYYY-MM-DD). <strong>Hinweis:</strong> Ohne <code>id</code> wird ein neues Mitglied erstellt. Mit <code>id</code> wird ein bestehendes Mitglied aktualisiert. Bei neuen Mitgliedern ist <code>geburtsdatum</code> zur Duplikatsprüfung Pflicht (Format: YYYY-MM-DD). Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -71,54 +71,84 @@
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Welcome Message --> <!-- Geburtstage Widget (statt Willkommens-Kachel) -->
<div class="bg-white p-8 rounded-xl shadow-lg border border-gray-100"> <div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4"> <div class="flex items-center mb-4">
Willkommen, {{ authStore.user?.name || 'Mitglied' }}! <div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
</h2> <Calendar
<p class="text-gray-600 mb-6">
Hier finden Sie alle wichtigen Informationen und Funktionen für Mitglieder des Harheimer TC.
</p>
<div class="grid sm:grid-cols-2 gap-4">
<div class="flex items-start">
<Check
:size="20" :size="20"
class="text-primary-600 mr-2 mt-0.5" class="text-pink-600"
/> />
<span class="text-gray-700">Zugriff auf Mitgliederliste mit Kontaktdaten</span>
</div>
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Vereinsnews und Ankündigungen</span>
</div>
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Profilverwaltung und Passwort ändern</span>
</div>
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Weitere Funktionen folgen in Kürze</span>
</div> </div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Geburtstage (nächste 4 Wochen)
</h2>
</div> </div>
<div
v-if="loadingBirthdays"
class="text-sm text-gray-500"
>
Lade...
</div>
<ul
v-else
class="space-y-2"
>
<li
v-for="b in birthdays"
:key="b.name + b.dayMonth"
class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"
>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">
{{ b.name }}
</div>
<div class="text-xs text-gray-600">
{{ b.dayMonth }}
</div>
</div>
<div class="text-sm text-gray-500">
{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}
</div>
</li>
<li
v-if="birthdays.length === 0"
class="text-sm text-gray-600"
>
Keine Geburtstage in den nächsten 4 Wochen.
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { User, Users, Newspaper, Check } from 'lucide-vue-next' import { User, Users, Newspaper, Check, Calendar } from 'lucide-vue-next'
import { ref, onMounted } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const birthdays = ref([])
const loadingBirthdays = ref(true)
const loadBirthdays = async () => {
loadingBirthdays.value = true
try {
const res = await $fetch('/api/birthdays')
birthdays.value = res.birthdays || []
} catch (e) {
console.error('Fehler beim Laden der Geburtstage', e)
birthdays.value = []
} finally {
loadingBirthdays.value = false
}
}
onMounted(() => {
loadBirthdays()
})
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
layout: 'default' layout: 'default'

View File

@@ -54,6 +54,39 @@
</div> </div>
</div> </div>
<!-- Sortieroptionen -->
<div class="mb-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center space-x-2">
<label
for="sortMode"
class="text-sm text-gray-700"
>Sortieren nach:</label>
<select
id="sortMode"
v-model="sortMode"
class="px-2 py-1 border rounded"
>
<option value="name">
Name (Vorname Nachname)
</option>
<option value="lastname">
Nachname (Nachname Vorname)
</option>
<option value="birthday">
Geburtstag
</option>
</select>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
v-model="filterHasHallKey"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
Nur mit Hallenschlüssel
</label>
</div>
<!-- Loading State --> <!-- Loading State -->
<div <div
v-if="isLoading" v-if="isLoading"
@@ -86,6 +119,9 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft Mannschaft
</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
🔑
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status Status
</th> </th>
@@ -99,13 +135,24 @@
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr <tr
v-for="member in members" v-for="member in sortedMembers"
:key="member.id" :key="member.id"
class="hover:bg-gray-50" class="hover:bg-gray-50"
> >
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900"> <div class="text-sm font-medium text-gray-900">
{{ member.name }} <template v-if="member.lastName || member.firstName">
{{ member.firstName }} {{ member.lastName }}
</template>
<template v-else>
{{ member.name }}
</template>
</div>
<div
v-if="member.birthday"
class="text-xs text-gray-500"
>
🎂 {{ formatBirthday(member.birthday) }}
</div> </div>
<div <div
v-if="member.notes" v-if="member.notes"
@@ -115,42 +162,30 @@
</div> </div>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData"> <template v-if="member.showEmail && member.email">
<a <a
v-if="member.email"
:href="`mailto:${member.email}`" :href="`mailto:${member.email}`"
class="text-sm text-primary-600 hover:text-primary-800" class="text-sm text-primary-600 hover:text-primary-800"
> >
{{ member.email }} {{ member.email }}
</a> </a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template> </template>
<span <template v-else>
v-else <span class="text-sm text-gray-400">Kontaktdaten nur für Vorstand sichtbar</span>
class="text-sm text-gray-400" </template>
>Nur für Vorstand</span>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData"> <template v-if="member.showPhone && member.phone">
<a <a
v-if="member.phone"
:href="`tel:${member.phone}`" :href="`tel:${member.phone}`"
class="text-sm text-primary-600 hover:text-primary-800" class="text-sm text-primary-600 hover:text-primary-800"
> >
{{ member.phone }} {{ member.phone }}
</a> </a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template> </template>
<span <template v-else>
v-else <span class="text-sm text-gray-400">Kontaktdaten nur für Vorstand sichtbar</span>
class="text-sm text-gray-400" </template>
>Nur für Vorstand</span>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<button <button
@@ -178,6 +213,15 @@
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }} {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span> </span>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="member.hasHallKey"
class="text-lg text-amber-600"
title="Hat Hallenschlüssel"
>
🔑
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
@@ -228,7 +272,7 @@
</div> </div>
<div <div
v-if="members.length === 0" v-if="sortedMembers.length === 0"
class="text-center py-12 text-gray-500" class="text-center py-12 text-gray-500"
> >
Keine Mitglieder gefunden. Keine Mitglieder gefunden.
@@ -241,7 +285,7 @@
class="space-y-4" class="space-y-4"
> >
<div <div
v-for="member in members" v-for="member in sortedMembers"
:key="member.id" :key="member.id"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
> >
@@ -249,8 +293,26 @@
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<h3 class="text-xl font-semibold text-gray-900"> <h3 class="text-xl font-semibold text-gray-900">
{{ member.name }} <template v-if="member.lastName || member.firstName">
{{ member.firstName }} {{ member.lastName }}
</template>
<template v-else>
{{ member.name }}
</template>
<span
v-if="member.birthday"
class="text-xs text-gray-500 ml-2"
>
🎂 {{ formatBirthday(member.birthday) }}
</span>
</h3> </h3>
<span
v-if="member.hasHallKey"
class="ml-2 text-amber-600"
title="Hat Hallenschlüssel"
>
🔑
</span>
<span <span
v-if="member.hasLogin" v-if="member.hasLogin"
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full" class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
@@ -296,9 +358,18 @@
</div> </div>
<div class="grid sm:grid-cols-2 gap-3 text-gray-600"> <div class="grid sm:grid-cols-2 gap-3 text-gray-600">
<template v-if="canViewContactData"> <template v-if="!(member.showEmail && member.email) && !(member.showPhone && member.phone)">
<div class="col-span-2 flex items-center text-gray-500 text-sm italic">
<Mail
:size="16"
class="mr-2"
/>
Kontaktdaten nur für Vorstand sichtbar
</div>
</template>
<template v-else>
<div <div
v-if="member.email" v-if="member.showEmail && member.email"
class="flex items-center" class="flex items-center"
> >
<Mail <Mail
@@ -311,7 +382,7 @@
>{{ member.email }}</a> >{{ member.email }}</a>
</div> </div>
<div <div
v-if="member.phone" v-if="member.showPhone && member.phone"
class="flex items-center" class="flex items-center"
> >
<Phone <Phone
@@ -324,15 +395,44 @@
>{{ member.phone }}</a> >{{ member.phone }}</a>
</div> </div>
</template> </template>
<div <!-- Sichtbarkeits-Flags anzeigen -->
v-else <div class="col-span-2 flex items-center gap-2 mt-2 text-xs text-gray-500">
class="col-span-2 flex items-center text-gray-500 text-sm italic" <span
> v-if="member.showEmail"
<Mail title="E-Mail sichtbar"
:size="16" >📧</span>
class="mr-2" <span
/> v-else
Kontaktdaten nur für Vorstand sichtbar title="E-Mail verborgen"
class="opacity-40"
>📧</span>
<span
v-if="member.showPhone"
title="Telefon sichtbar"
>📞</span>
<span
v-else
title="Telefon verborgen"
class="opacity-40"
>📞</span>
<span
v-if="member.showAddress"
title="Adresse sichtbar"
>🏠</span>
<span
v-else
title="Adresse verborgen"
class="opacity-40"
>🏠</span>
<span
v-if="member.showBirthday"
title="Geburtstag sichtbar"
>🎂</span>
<span
v-else
title="Geburtstag verborgen"
class="opacity-40"
>🎂</span>
</div> </div>
<div <div
v-if="member.address" v-if="member.address"
@@ -390,7 +490,7 @@
</div> </div>
<div <div
v-if="members.length === 0" v-if="sortedMembers.length === 0"
class="text-center py-12 text-gray-500" class="text-center py-12 text-gray-500"
> >
Keine Mitglieder gefunden. Keine Mitglieder gefunden.
@@ -440,12 +540,12 @@
<input <input
v-model="formData.geburtsdatum" v-model="formData.geburtsdatum"
type="date" type="date"
required :required="isBirthdateRequired"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving" :disabled="isSaving"
> >
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Wird zur eindeutigen Identifizierung benötigt Für neue Mitglieder erforderlich. Altdaten ohne Geburtsdatum können weiter bearbeitet werden.
</p> </p>
</div> </div>
@@ -505,6 +605,22 @@
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="hasHallKey"
v-model="formData.hasHallKey"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving"
>
<label
for="hasHallKey"
class="ml-2 block text-sm font-medium text-gray-700"
>
Hat Hallenschlüssel
</label>
</div>
<div <div
v-if="errorMessage" v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
@@ -770,6 +886,82 @@
</template> </template>
<script setup> <script setup>
// ...existing code...
const sortMode = ref('name')
const filterHasHallKey = ref(false)
const sortedMembers = computed(() => {
if (!Array.isArray(members.value)) return []
const arr = filterHasHallKey.value
? members.value.filter(member => member.hasHallKey)
: [...members.value]
if (sortMode.value === 'name') {
arr.sort((a, b) => {
// Sortiere nach Vorname Nachname (firstName lastName)
const af = (a.firstName || '').toLocaleLowerCase()
const bf = (b.firstName || '').toLocaleLowerCase()
const al = (a.lastName || '').toLocaleLowerCase()
const bl = (b.lastName || '').toLocaleLowerCase()
if (af === bf) return al.localeCompare(bl)
return af.localeCompare(bf)
})
} else if (sortMode.value === 'lastname') {
arr.sort((a, b) => {
// Sortiere nach Nachname, dann Vorname
const al = (a.lastName || '').toLocaleLowerCase()
const bl = (b.lastName || '').toLocaleLowerCase()
if (al === bl) {
const af = (a.firstName || '').toLocaleLowerCase()
const bf = (b.firstName || '').toLocaleLowerCase()
return af.localeCompare(bf)
}
return al.localeCompare(bl)
})
} else if (sortMode.value === 'birthday') {
arr.sort((a, b) => {
// Robust: akzeptiere YYYY-MM-DD, DD.MM.YYYY, ggf. nur MM-TT
function parseBirthday(val) {
if (!val) return null
if (val.includes('-')) {
const parts = val.split('-')
if (parts.length === 3) return { m: parts[1].padStart(2, '0'), d: parts[2].padStart(2, '0') }
} else if (val.includes('.')) {
const parts = val.split('.')
if (parts.length >= 2) return { d: parts[0].padStart(2, '0'), m: parts[1].padStart(2, '0') }
}
return null
}
const ad = parseBirthday(a.birthday)
const bd = parseBirthday(b.birthday)
if (!ad && !bd) return 0
if (!ad) return 1
if (!bd) return -1
// Monat zuerst, dann Tag
if (ad.m === bd.m) return ad.d.localeCompare(bd.d)
return ad.m.localeCompare(bd.m)
})
}
return arr
})
function formatBirthday(dateStr) {
// Erwartet YYYY-MM-DD oder DD.MM.YYYY
if (!dateStr) return ''
if (dateStr.includes('-')) {
const [, m, d] = dateStr.split('-')
return `${d}.${m}.`
} else if (dateStr.includes('.')) {
const parts = dateStr.split('.')
if (parts.length >= 2) return `${parts[0]}.${parts[1]}.`
}
return dateStr
}
// members muss showBirthday und birthday enthalten:
// showBirthday: true, wenn das Mitglied die Anzeige erlaubt
// birthday: im Format YYYY-MM-DD oder DD.MM.YYYY
// Falls die Datenstruktur anders ist, bitte anpassen!
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next' import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'
@@ -800,7 +992,8 @@ const formData = ref({
phone: '', phone: '',
address: '', address: '',
notes: '', notes: '',
isMannschaftsspieler: false isMannschaftsspieler: false,
hasHallKey: false
}) })
const canEdit = computed(() => { const canEdit = computed(() => {
@@ -812,6 +1005,10 @@ const canViewContactData = computed(() => {
return authStore.hasRole('vorstand') return authStore.hasRole('vorstand')
}) })
const isBirthdateRequired = computed(() => {
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
})
const loadMembers = async () => { const loadMembers = async () => {
isLoading.value = true isLoading.value = true
try { try {
@@ -834,7 +1031,8 @@ const openAddModal = () => {
phone: '', phone: '',
address: '', address: '',
notes: '', notes: '',
isMannschaftsspieler: false isMannschaftsspieler: false,
hasHallKey: false
} }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -850,7 +1048,8 @@ const openEditModal = (member) => {
phone: member.phone || '', phone: member.phone || '',
address: member.address || '', address: member.address || '',
notes: member.notes || '', notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true isMannschaftsspieler: member.isMannschaftsspieler === true,
hasHallKey: member.hasHallKey === true
} }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''

View File

@@ -77,6 +77,70 @@
> >
</div> </div>
<div>
<label
for="geburtsdatum"
class="block text-sm font-medium text-gray-700 mb-2"
>
Geburtsdatum
</label>
<input
id="geburtsdatum"
v-model="formData.geburtsdatum"
type="date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
<p class="mt-1 text-xs text-gray-500">
Sichtbar ist in der Mitgliederliste nur der Geburtstag, wenn Sie ihn unten freigeben.
</p>
</div>
<!-- Sichtbarkeits-Einstellungen -->
<div class="mt-4 border-t border-gray-100 pt-4">
<h3 class="text-sm font-medium text-gray-900 mb-2">
Sichtbarkeit für andere Mitglieder
</h3>
<div class="flex flex-col gap-2 text-sm text-gray-700">
<label class="inline-flex items-center">
<input
v-model="visibility.showEmail"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
E-Mail für alle eingeloggten Mitglieder sichtbar
</label>
<label class="inline-flex items-center">
<input
v-model="visibility.showPhone"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
Telefonnummer für alle eingeloggten Mitglieder sichtbar
</label>
<label class="inline-flex items-center">
<input
v-model="visibility.showAddress"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
Adresse für alle eingeloggten Mitglieder sichtbar
</label>
<label class="inline-flex items-center">
<input
v-model="visibility.showBirthday"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
Geburtstag für alle eingeloggten Mitglieder sichtbar
</label>
</div>
</div>
<!-- Passwort ändern --> <!-- Passwort ändern -->
<div class="border-t border-gray-200 pt-6 mt-6"> <div class="border-t border-gray-200 pt-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4"> <h3 class="text-lg font-semibold text-gray-900 mb-4">
@@ -276,7 +340,16 @@ if (process.client) {
const formData = ref({ const formData = ref({
name: '', name: '',
email: '', email: '',
phone: '' phone: '',
geburtsdatum: ''
})
// Visibility preferences for other logged-in members
const visibility = ref({
showEmail: true,
showPhone: true,
showAddress: false,
showBirthday: true
}) })
const passwordData = ref({ const passwordData = ref({
@@ -295,8 +368,10 @@ const loadProfile = async () => {
formData.value = { formData.value = {
name: response.user.name, name: response.user.name,
email: response.user.email, email: response.user.email,
phone: response.user.phone || '' phone: response.user.phone || '',
geburtsdatum: response.user.geburtsdatum || ''
} }
visibility.value = response.user.visibility || visibility.value
} catch { } catch {
errorMessage.value = 'Fehler beim Laden des Profils.' errorMessage.value = 'Fehler beim Laden des Profils.'
} finally { } finally {
@@ -398,6 +473,8 @@ const handleSave = async () => {
name: formData.value.name, name: formData.value.name,
email: formData.value.email, email: formData.value.email,
phone: formData.value.phone, phone: formData.value.phone,
geburtsdatum: formData.value.geburtsdatum,
visibility: visibility.value,
currentPassword: passwordData.value.current || undefined, currentPassword: passwordData.value.current || undefined,
newPassword: passwordData.value.new || undefined newPassword: passwordData.value.new || undefined
} }

View File

@@ -32,22 +32,40 @@
</div> </div>
--> -->
<!-- Name --> <!-- Vorname -->
<div> <div>
<label <label
for="name" for="firstName"
class="block text-sm font-medium text-gray-700 mb-2" class="block text-sm font-medium text-gray-700 mb-2"
> >
Vollständiger Name Vorname
</label> </label>
<input <input
id="name" id="firstName"
v-model="formData.name" v-model="formData.firstName"
type="text" type="text"
required required
autocomplete="name" autocomplete="given-name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
placeholder="Max Mustermann" placeholder="Max"
>
</div>
<!-- Nachname -->
<div>
<label
for="lastName"
class="block text-sm font-medium text-gray-700 mb-2"
>
Nachname
</label>
<input
id="lastName"
v-model="formData.lastName"
type="text"
required
autocomplete="family-name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
placeholder="Mustermann"
> >
</div> </div>
@@ -88,6 +106,31 @@
> >
</div> </div>
<div>
<label
for="geburtsdatum"
class="block text-sm font-medium text-gray-700 mb-2"
>
Geburtsdatum
</label>
<input
id="geburtsdatum"
v-model="formData.geburtsdatum"
type="date"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input
v-model="formData.hideBirthday"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-600 border-gray-300 rounded"
>
Geburtsdatum in der Mitgliederliste nicht anzeigen
</label>
<!-- Password --> <!-- Password -->
<div> <div>
<label <label
@@ -130,8 +173,10 @@
</div> </div>
<!-- Optional password toggle for passkey users - vorläufig deaktiviert --> <!-- Optional password toggle for passkey users - vorläufig deaktiviert -->
<!-- <div
<div v-if="usePasskey" class="flex items-center gap-2 text-sm text-gray-700"> v-if="false"
class="flex items-center gap-2 text-sm text-gray-700"
>
<input <input
v-model="setPasswordForPasskey" v-model="setPasswordForPasskey"
type="checkbox" type="checkbox"
@@ -174,17 +219,23 @@
v-if="false" v-if="false"
class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-xs space-y-3" class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-xs space-y-3"
> >
<div class="font-semibold text-blue-900 mb-2">🔍 Debug-Informationen (QR-Code):</div> <div class="font-semibold text-blue-900 mb-2">
🔍 Debug-Informationen (QR-Code):
</div>
<div class="space-y-1 text-blue-800"> <div class="space-y-1 text-blue-800">
<div><strong>Challenge:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugChallenge }}</code></div> <div><strong>Challenge:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugChallenge }}</code></div>
<div><strong>RP-ID:</strong> <code class="bg-blue-100 px-1 rounded">{{ debugRpId }}</code></div> <div><strong>RP-ID:</strong> <code class="bg-blue-100 px-1 rounded">{{ debugRpId }}</code></div>
<div v-if="debugRegistrationId"><strong>Registration-ID:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugRegistrationId }}</code></div> <div v-if="debugRegistrationId">
<strong>Registration-ID:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugRegistrationId }}</code>
</div>
<div><strong>Origin:</strong> <code class="bg-blue-100 px-1 rounded">{{ typeof window !== 'undefined' ? window.location.origin : 'N/A (SSR)' }}</code></div> <div><strong>Origin:</strong> <code class="bg-blue-100 px-1 rounded">{{ typeof window !== 'undefined' ? window.location.origin : 'N/A (SSR)' }}</code></div>
</div> </div>
<!-- FIDO QR-Code Info --> <!-- FIDO QR-Code Info -->
<div class="mt-3 p-3 bg-purple-50 border border-purple-300 rounded"> <div class="mt-3 p-3 bg-purple-50 border border-purple-300 rounded">
<div class="font-semibold text-purple-900 mb-2">🔐 FIDO Cross-Device Info:</div> <div class="font-semibold text-purple-900 mb-2">
🔐 FIDO Cross-Device Info:
</div>
<div class="text-xs text-purple-800 space-y-2"> <div class="text-xs text-purple-800 space-y-2">
<div><strong>QR-Code-Format:</strong> FIDO-URI (enthält öffentlichen Schlüssel + Secret)</div> <div><strong>QR-Code-Format:</strong> FIDO-URI (enthält öffentlichen Schlüssel + Secret)</div>
<div><strong>Hinweis:</strong> Der QR-Code enthält einen FIDO-URI, der vom Smartphone gescannt werden muss.</div> <div><strong>Hinweis:</strong> Der QR-Code enthält einen FIDO-URI, der vom Smartphone gescannt werden muss.</div>
@@ -227,10 +278,19 @@
</div> </div>
<!-- Smartphone URL --> <!-- Smartphone URL -->
<div v-if="debugSmartphoneUrl" class="mt-3 p-3 bg-green-50 border border-green-300 rounded"> <div
<div class="font-semibold text-green-900 mb-2">📱 Alternative: Smartphone-URL (manuell öffnen):</div> v-if="debugSmartphoneUrl"
class="mt-3 p-3 bg-green-50 border border-green-300 rounded"
>
<div class="font-semibold text-green-900 mb-2">
📱 Alternative: Smartphone-URL (manuell öffnen):
</div>
<div class="break-all text-xs mb-2 p-2 bg-white rounded border"> <div class="break-all text-xs mb-2 p-2 bg-white rounded border">
<a :href="debugSmartphoneUrl" target="_blank" class="text-blue-600 hover:underline"> <a
:href="debugSmartphoneUrl"
target="_blank"
class="text-blue-600 hover:underline"
>
{{ debugSmartphoneUrl }} {{ debugSmartphoneUrl }}
</a> </a>
</div> </div>
@@ -238,23 +298,26 @@
<strong>Anleitung:</strong> Falls der QR-Code nicht funktioniert, öffnen Sie diese URL manuell auf Ihrem Smartphone. <strong>Anleitung:</strong> Falls der QR-Code nicht funktioniert, öffnen Sie diese URL manuell auf Ihrem Smartphone.
</div> </div>
<button <button
@click="copyToClipboard(debugSmartphoneUrl)"
class="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700" class="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
@click="copyToClipboard(debugSmartphoneUrl)"
> >
📋 URL kopieren 📋 URL kopieren
</button> </button>
</div> </div>
<!-- Full Options JSON --> <!-- Full Options JSON -->
<div v-if="debugOptions" class="mt-3"> <div
v-if="debugOptions"
class="mt-3"
>
<details class="text-xs"> <details class="text-xs">
<summary class="cursor-pointer font-semibold text-blue-900 hover:text-blue-700 mb-2"> <summary class="cursor-pointer font-semibold text-blue-900 hover:text-blue-700 mb-2">
📄 Vollständige Options (JSON) - Klicken zum Anzeigen 📄 Vollständige Options (JSON) - Klicken zum Anzeigen
</summary> </summary>
<pre class="mt-2 p-2 bg-gray-100 rounded overflow-auto text-xs max-h-60 border">{{ JSON.stringify(debugOptions, null, 2) }}</pre> <pre class="mt-2 p-2 bg-gray-100 rounded overflow-auto text-xs max-h-60 border">{{ JSON.stringify(debugOptions, null, 2) }}</pre>
<button <button
@click="copyToClipboard(JSON.stringify(debugOptions, null, 2))"
class="mt-2 px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700" class="mt-2 px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700"
@click="copyToClipboard(JSON.stringify(debugOptions, null, 2))"
> >
📋 JSON kopieren 📋 JSON kopieren
</button> </button>
@@ -318,9 +381,12 @@ import { AlertCircle, Check, Loader2, Info } from 'lucide-vue-next'
// console.log('[DEBUG] Component setup started') // console.log('[DEBUG] Component setup started')
const formData = ref({ const formData = ref({
name: '', firstName: '',
lastName: '',
email: '', email: '',
phone: '', phone: '',
geburtsdatum: '',
hideBirthday: false,
password: '', password: '',
confirmPassword: '' confirmPassword: ''
}) })
@@ -400,9 +466,15 @@ const handleRegister = async () => {
const response = await $fetch('/api/auth/register', { const response = await $fetch('/api/auth/register', {
method: 'POST', method: 'POST',
body: { body: {
name: formData.value.name, firstName: formData.value.firstName,
lastName: formData.value.lastName,
name: `${formData.value.firstName} ${formData.value.lastName}`.trim(),
email: formData.value.email, email: formData.value.email,
phone: formData.value.phone, phone: formData.value.phone,
geburtsdatum: formData.value.geburtsdatum,
visibility: {
showBirthday: !formData.value.hideBirthday
},
password: formData.value.password password: formData.value.password
} }
}) })
@@ -412,9 +484,12 @@ const handleRegister = async () => {
// Reset form // Reset form
formData.value = { formData.value = {
name: '', firstName: '',
lastName: '',
email: '', email: '',
phone: '', phone: '',
geburtsdatum: '',
hideBirthday: false,
password: '', password: '',
confirmPassword: '' confirmPassword: ''
} }

View File

@@ -0,0 +1,25 @@
Split-Name Scripts
Diese Scripts helfen, das Feld `name` in `firstName` und `lastName` zu splitten, für verschiedene Datenquellen im Projekt.
Available scripts:
- `scripts/split-names-in-users.js` (CommonJS)
- Splittet `server/data/users.json` und ergänzt fehlende `firstName`/`lastName`.
- Erstellt ein Backup `users.json.bak.<timestamp>` falls Änderungen gemacht werden.
- Ausführen: `node scripts/split-names-in-users.js`
- `scripts/split-names-in-members.js` (ESM)
- Liest `members.json` über `server/utils/members.js` (beachtet Verschlüsselung), führt Dry-Run by default.
- Mit `--apply` werden Änderungen geschrieben und ein Backup erstellt.
- Ausführen (dry-run): `node scripts/split-names-in-members.js`
- Ausführen (apply): `node scripts/split-names-in-members.js --apply`
- `scripts/split-names-in-membership-apps.js` (CommonJS)
- Bearbeitet alle JSON-Dateien in `server/data/membership-applications/` und erstellt `.bak` Backups pro Datei.
- Ausführen: `node scripts/split-names-in-membership-apps.js`
Hinweis:
- Die Scripts sind vorsichtig: sie erstellen Backups bevor sie schreiben (außer beim Dry-Run für members.js).
- `split-names-in-members.js` nutzt die vorhandenen `readMembers`/`writeMembers` Utilities, um Verschlüsselung zu respektieren.
- Teste zuerst mit DRY-RUN oder in einer Kopie des Datenverzeichnisses.

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Gibt einem bestehenden Benutzer zusaetzlich die Rolle "vorstand".
*
* Verwendung:
* node scripts/add-vorstand-role.js
* node scripts/add-vorstand-role.js <email>
*
* Standard-E-Mail:
* tsschulz@gmx.net
*/
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
import dotenv from 'dotenv'
import { readUsers, writeUsers, migrateUserRoles } from '../server/utils/auth.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
dotenv.config({ path: path.join(__dirname, '..', '.env') })
const targetEmail = String(process.argv[2] || 'tsschulz@gmx.net').trim().toLowerCase()
function getUsersFilePath() {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return `${cwd}/../server/data/users.json`
}
return `${cwd}/server/data/users.json`
}
async function createBackup(filePath) {
const backupDir = path.join(__dirname, '..', 'backups', `users-${Date.now()}`)
await fs.mkdir(backupDir, { recursive: true })
const backupPath = path.join(backupDir, 'users.json')
await fs.copyFile(filePath, backupPath)
return backupPath
}
async function main() {
const usersFile = getUsersFilePath()
console.log(`Suche Benutzer: ${targetEmail}`)
const users = await readUsers()
const user = users.find((entry) => String(entry.email || '').trim().toLowerCase() === targetEmail)
if (!user) {
console.error(`Benutzer nicht gefunden: ${targetEmail}`)
process.exit(1)
}
migrateUserRoles(user)
const currentRoles = Array.isArray(user.roles) ? [...user.roles] : []
if (currentRoles.includes('vorstand')) {
console.log(`Benutzer ${targetEmail} hat die Rolle "vorstand" bereits.`)
console.log(`Aktuelle Rollen: ${currentRoles.join(', ')}`)
return
}
const backupPath = await createBackup(usersFile)
user.roles = [...new Set([...currentRoles, 'vorstand'])]
const success = await writeUsers(users)
if (!success) {
console.error('Fehler beim Schreiben der Benutzerdaten.')
process.exit(1)
}
console.log(`Backup erstellt: ${backupPath}`)
console.log(`Rolle "vorstand" hinzugefuegt fuer ${targetEmail}`)
console.log(`Aktuelle Rollen: ${user.roles.join(', ')}`)
}
main().catch((error) => {
console.error('Fehler beim Hinzufuegen der Rolle "vorstand":', error)
process.exit(1)
})

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_REF="${1:-origin/main}"
BASE_BRANCH="${BASE_REF#origin/}"
git fetch --no-tags --depth=1 origin "$BASE_BRANCH"
current_version="$(node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); process.stdout.write(String(pkg.version || ""));')"
base_version="$(git show "$BASE_REF:package.json" | node -e 'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const pkg = JSON.parse(input); process.stdout.write(String(pkg.version || "")); });')"
if [ -z "$current_version" ]; then
echo "ERROR: package.json enthält kein version-Feld."
exit 1
fi
if [ "$current_version" = "$base_version" ]; then
echo "ERROR: package.json version wurde nicht geändert."
echo "Base ($BASE_REF): $base_version"
echo "Current: $current_version"
echo "Bitte version in package.json erhöhen, bevor nach main gemerged wird."
exit 1
fi
echo "package.json version changed: $base_version -> $current_version"

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
(async () => {
try {
const { readMembers } = await import('../server/utils/members.js')
const auth = await import('../server/utils/auth.js')
const { readUsers } = auth
const manual = await readMembers()
const users = await readUsers()
// Build simple merged list similar to members.get
const merged = []
// Add manual members
for (const m of manual) {
const fullName = `${m.firstName || ''} ${m.lastName || ''}`.trim()
const vis = m.visibility || {}
const visibility = {
showEmail: vis.showEmail === undefined ? false : Boolean(vis.showEmail),
showPhone: vis.showPhone === undefined ? false : Boolean(vis.showPhone),
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
}
merged.push({
id: m.id || null,
name: fullName || m.name || '(kein name)',
email: m.email || '',
phone: m.phone || '',
address: m.address || '',
source: 'manual',
visibility,
raw: m
})
}
// Add registered users (default visibility: false unless stored)
for (const u of users) {
if (!u.active) continue
const visibility = u.visibility || { showEmail: false, showPhone: false, showAddress: false }
merged.push({
id: u.id,
name: u.name,
email: u.email || '',
phone: u.phone || '',
address: u.address || '',
source: 'login',
visibility,
raw: u
})
}
merged.sort((a, b) => a.name.localeCompare(b.name))
const viewers = [
{ label: 'unauthenticated', isPrivileged: false },
{ label: 'admin', isPrivileged: false },
{ label: 'vorstand', isPrivileged: true }
]
for (const v of viewers) {
console.log('\n=== Viewer:', v.label, ' (vorstand override:', v.isPrivileged, ') ===')
for (const m of merged) {
const hadEmail = !!m.email
const hadPhone = !!m.phone
const showEmail = v.isPrivileged || Boolean(m.visibility.showEmail)
const showPhone = v.isPrivileged || Boolean(m.visibility.showPhone)
const contactHidden = (!showEmail && hadEmail) || (!showPhone && hadPhone)
console.log(`- ${m.name}`)
console.log(` source: ${m.source} roles?: ${m.raw.roles || m.raw.role || ''}`)
console.log(` email: ${hadEmail ? (showEmail ? m.email : '<HIDDEN>') : '-'}`)
console.log(` phone: ${hadPhone ? (showPhone ? m.phone : '<HIDDEN>') : '-'}`)
if (contactHidden) console.log(' -> contactHidden = true')
}
}
process.exit(0)
} catch (e) {
console.error('ERROR', e)
process.exit(2)
}
})()

View File

@@ -0,0 +1,22 @@
// Diagnose-Skript: Zeigt alle Mitglieder aus members.json mit Status und Sichtbarkeit
import { readMembers } from '../server/utils/members.js'
async function main() {
const members = await readMembers()
if (!members || members.length === 0) {
console.log('Keine Mitglieder geladen (members.json leer oder nicht entschlüsselbar)')
return
}
for (const m of members) {
const status = m.active === true ? 'active' : (m.status ? m.status : 'inactive')
const vis = m.visibility || {}
console.log(`ID: ${m.id || '-'} | Name: ${m.firstName || ''} ${m.lastName || ''}`)
console.log(` Status: ${status}`)
console.log(` Email: ${m.email || '-'} | Phone: ${m.phone || '-'}`)
console.log(` Sichtbarkeit:`, vis)
console.log('---')
}
console.log(`Insgesamt: ${members.length} Mitglieder geladen.`)
}
main()

View File

@@ -2,14 +2,14 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import sharp from 'sharp' import sharp from 'sharp'
const getDataPath = (filename) => { const getDataRoot = () => {
const cwd = process.cwd() const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename) return cwd.endsWith('.output') ? `${cwd}/../server/data` : `${cwd}/server/data`
return path.join(cwd, 'server/data', filename)
} }
const GALERIE_DIR = getDataPath('galerie') const DATA_ROOT = getDataRoot()
const GALERIE_METADATA = getDataPath('galerie-metadata.json') const GALERIE_DIR = `${DATA_ROOT}/galerie`
const GALERIE_METADATA = `${DATA_ROOT}/galerie-metadata.json`
async function readJsonArray(file) { async function readJsonArray(file) {
try { try {
@@ -41,14 +41,16 @@ async function fileExists(p) {
} }
async function generatePreviewForEntry(entry, size) { async function generatePreviewForEntry(entry, size) {
const original = path.join(GALERIE_DIR, 'originals', entry.filename) const safeOriginal = path.basename(String(entry.filename || ''))
const original = `${GALERIE_DIR}/originals/${safeOriginal}`
if (!(await fileExists(original))) return { ok: false, reason: 'missing original' } if (!(await fileExists(original))) return { ok: false, reason: 'missing original' }
const previewFilename = entry.previewFilename && String(entry.previewFilename).trim() !== '' const previewFilename = entry.previewFilename && String(entry.previewFilename).trim() !== ''
? entry.previewFilename ? entry.previewFilename
: `preview_${entry.filename}` : `preview_${entry.filename}`
const preview = path.join(GALERIE_DIR, 'previews', previewFilename) const safePreview = path.basename(String(previewFilename || ''))
const preview = `${GALERIE_DIR}/previews/${safePreview}`
await sharp(original) await sharp(original)
.rotate() .rotate()

View File

@@ -60,25 +60,35 @@ async function inspect(pdfPath) {
async function main() { async function main() {
const repoRoot = process.cwd() const repoRoot = process.cwd()
const template = path.join(repoRoot, 'server', 'templates', 'mitgliedschaft-fillable.pdf') const template = path.join(repoRoot, 'server', 'templates', 'mitgliedschaft-fillable.pdf')
// pick latest generated PDF in public/uploads that is not the sample
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // Prefer internal upload directory used by the API (server/data/uploads).
const uploads = path.join(repoRoot, 'public', 'uploads') // If legacy files exist in public/uploads, warn and inspect them as well.
const internalUploads = path.join(repoRoot, 'server', 'data', 'uploads')
const publicUploads = path.join(repoRoot, 'public', 'uploads')
let pdfFiles = [] let pdfFiles = []
if (fs.existsSync(uploads)) { if (fs.existsSync(internalUploads)) {
pdfFiles = fs.readdirSync(uploads).filter(f => f.toLowerCase().endsWith('.pdf')) pdfFiles = fs.readdirSync(internalUploads).filter(f => f.toLowerCase().endsWith('.pdf'))
.map(f => { .map(f => {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal const safeName = path.basename(String(f || ''))
const filePath = path.join(uploads, f) const filePath = `${internalUploads}/${safeName}`
return { f, mtime: fs.statSync(filePath).mtimeMs } return { f, mtime: fs.statSync(filePath).mtimeMs, dir: internalUploads }
}) })
.sort((a,b) => b.mtime - a.mtime)
.map(x => x.f)
} }
const apiPdf = pdfFiles.find(n => !n.includes('sample')) || pdfFiles[0]
// Do NOT fall back to public/uploads to avoid encouraging public exposure.
if (pdfFiles.length === 0) {
if (fs.existsSync(publicUploads)) {
console.warn('WARN: PDFs exist in public/uploads. Please migrate them to server/data/uploads using scripts/migrate-public-galerie-to-metadata.js')
}
}
pdfFiles = pdfFiles.sort((a, b) => b.mtime - a.mtime)
const apiPdfEntry = pdfFiles.find(e => !e.f.includes('sample')) || pdfFiles[0]
await inspect(template) await inspect(template)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal if (apiPdfEntry) await inspect(path.join(apiPdfEntry.dir, apiPdfEntry.f))
if (apiPdf) await inspect(path.join(uploads, apiPdf)) else console.log('No API-generated PDF found in server/data/uploads or public/uploads')
else console.log('No API-generated PDF found in public/uploads')
} }
main().catch(e => { console.error(e); process.exit(1) }) main().catch(e => { console.error(e); process.exit(1) })

View File

@@ -4,14 +4,14 @@ import { randomUUID } from 'crypto'
const allowed = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']) const allowed = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'])
const getDataPath = (filename) => { const getDataRoot = () => {
const cwd = process.cwd() const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename) return cwd.endsWith('.output') ? `${cwd}/../server/data` : `${cwd}/server/data`
return path.join(cwd, 'server/data', filename)
} }
const GALERIE_DIR = getDataPath('galerie') const DATA_ROOT = getDataRoot()
const GALERIE_METADATA = getDataPath('galerie-metadata.json') const GALERIE_DIR = `${DATA_ROOT}/galerie`
const GALERIE_METADATA = `${DATA_ROOT}/galerie-metadata.json`
const PUBLIC_GALERIE_DIR = path.join(process.cwd(), 'public', 'galerie') const PUBLIC_GALERIE_DIR = path.join(process.cwd(), 'public', 'galerie')
function titleFromFilename(filename) { function titleFromFilename(filename) {

View File

@@ -0,0 +1,55 @@
// Re-Encrypt Klartext-Mitgliedsanträge mit aktuellem ENCRYPTION_KEY
// Backup wird als .bak angelegt
import fs from 'fs/promises'
import path from 'path'
import { encryptObject } from '../server/utils/encryption.js'
const DIR = path.join(process.cwd(), 'server/data/membership-applications')
const KEY = process.env.ENCRYPTION_KEY
if (!KEY) {
console.error('ENCRYPTION_KEY fehlt! Bitte als Environment-Variable setzen.')
process.exit(1)
}
async function reencryptFile(file) {
const safeFile = path.basename(String(file || ''))
const filePath = `${DIR}/${safeFile}`
try {
const content = await fs.readFile(filePath, 'utf8')
// Prüfe, ob bereits verschlüsselt (v2: Prefix)
if (content.startsWith('v2:')) {
console.log('Überspringe (bereits verschlüsselt):', file)
return false
}
// Prüfe, ob Klartext-JSON
if (!content.trim().startsWith('{')) {
console.warn('Überspringe (kein Klartext-JSON):', file)
return false
}
// Backup anlegen
await fs.copyFile(filePath, filePath + '.bak')
// Verschlüsseln
const obj = JSON.parse(content)
const encrypted = encryptObject(obj, KEY)
await fs.writeFile(filePath, encrypted, 'utf8')
console.log('Re-Encrypted:', file)
return true
} catch (e) {
console.error('Fehler bei', file, ':', e.message)
return false
}
}
async function main() {
const files = await fs.readdir(DIR)
let changed = 0
for (const file of files) {
if (!file.endsWith('.json')) continue
const ok = await reencryptFile(file)
if (ok) changed++
}
console.log('Fertig. Re-encrypted:', changed, 'Dateien.')
}
main()

View File

@@ -0,0 +1,22 @@
// Setzt für alle Mitglieder in members.json das Feld active: true und verschlüsselt neu
import 'dotenv/config'
import { readMembers, writeMembers } from '../server/utils/members.js'
async function main() {
const members = await readMembers()
if (!members || members.length === 0) {
console.log('Keine Mitglieder geladen (members.json leer oder nicht entschlüsselbar)')
return
}
let changed = 0
for (const m of members) {
if (m.active !== true) {
m.active = true
changed++
}
}
await writeMembers(members)
console.log(`Fertig. ${changed} Mitglieder auf active: true gesetzt und gespeichert.`)
}
main()

69
scripts/set-visibility.js Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
import arg from 'arg'
async function main() {
const args = arg({
'--email': String,
'--showEmail': Boolean,
'--showPhone': Boolean,
'--showAddress': Boolean,
'--target': String // 'members'|'users'|'both'
})
const email = args['--email']
if (!email) {
console.error('Usage: node scripts/set-visibility.js --email <email> [--showEmail] [--showPhone] [--showAddress] [--target both|members|users]')
process.exit(2)
}
const showEmail = '--showEmail' in args ? Boolean(args['--showEmail']) : undefined
const showPhone = '--showPhone' in args ? Boolean(args['--showPhone']) : undefined
const showAddress = '--showAddress' in args ? Boolean(args['--showAddress']) : undefined
const target = args['--target'] || 'both'
const membersUtils = await import('../server/utils/members.js')
const authUtils = await import('../server/utils/auth.js')
if (target === 'both' || target === 'members') {
const members = await membersUtils.readMembers()
let changed = false
for (const m of members) {
if ((m.email || '').toLowerCase() === email.toLowerCase()) {
m.visibility = m.visibility || {}
if (showEmail !== undefined) m.visibility.showEmail = showEmail
if (showPhone !== undefined) m.visibility.showPhone = showPhone
if (showAddress !== undefined) m.visibility.showAddress = showAddress
changed = true
console.log('Updated manual member visibility for', email)
}
}
if (changed) {
await membersUtils.writeMembers(members)
console.log('Wrote members.json')
}
}
if (target === 'both' || target === 'users') {
const users = await authUtils.readUsers()
let changed = false
for (const u of users) {
if ((u.email || '').toLowerCase() === email.toLowerCase()) {
u.visibility = u.visibility || {}
if (showEmail !== undefined) u.visibility.showEmail = showEmail
if (showPhone !== undefined) u.visibility.showPhone = showPhone
if (showAddress !== undefined) u.visibility.showAddress = showAddress
changed = true
console.log('Updated user visibility for', email)
}
}
if (changed) {
await authUtils.writeUsers(users)
console.log('Wrote users.json')
}
}
}
main().catch(e => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
import fs from 'fs'
import { promises as fsp } from 'fs'
import path from 'path'
import { readMembers, writeMembers } from '../server/utils/members.js'
// Script to split `name` into firstName/lastName for members.json.
// Usage:
// node scripts/split-names-in-members.js # dry-run, no writes
// node scripts/split-names-in-members.js --apply # apply changes and create backup
const MEMBERS_FILE_PATH = path.join(process.cwd(), 'server/data/members.json')
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
async function main() {
const apply = process.argv.includes('--apply')
console.log('Reading members via server utils (handles encryption)...')
const members = await readMembers()
if (!Array.isArray(members)) {
console.error('Unerwartetes Format von members:', typeof members)
process.exit(2)
}
let changed = false
for (const m of members) {
if ((!m.firstName || !m.lastName) && m.name) {
const { firstName, lastName } = extractNames(m.name)
if (!m.firstName) m.firstName = firstName
if (!m.lastName) m.lastName = lastName
changed = true
}
}
if (!changed) {
console.log('Keine Änderungen erforderlich. Alle Mitglieder haben firstName/lastName.')
return
}
console.log(`Gefundene Änderungen: Mitglieder mit ergänztenn Namen werden ${apply ? 'angewendet' : 'nur angezeigt (dry-run)'}.`)
if (!apply) {
console.log('Vorschau der Änderungen (erstes 10 geänderte Mitglieder):')
let count = 0
for (const m of members) {
if (m.firstName || m.lastName) {
console.log('-', m.id || '(keine id)', m.firstName, m.lastName, '-', m.name)
count++
if (count >= 10) break
}
}
console.log('\nFühre das Skript mit --apply aus, um die Änderungen dauerhaft zu schreiben (Backup wird erstellt).')
return
}
// Create backup of raw file (may be encrypted)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = MEMBERS_FILE_PATH + `.bak.${timestamp}`
try {
await fsp.copyFile(MEMBERS_FILE_PATH, backupPath)
console.log('Backup erstellt:', backupPath)
} catch (err) {
console.warn('Konnte kein Backup anlegen (Datei evtl. nicht vorhanden):', err.message)
}
// Write members using writeMembers (will handle encryption)
await writeMembers(members)
console.log('Mitglieder erfolgreich aktualisiert und verschlüsselt gespeichert.')
}
main().catch(err => {
console.error('Fehler:', err)
process.exit(1)
})

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Script to split name field in membership application JSON files under server/data/membership-applications/ (ESM)
// It will create backups for each modified file.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const APPS_DIR = path.join(__dirname, '../server/data/membership-applications')
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
function main() {
if (!fs.existsSync(APPS_DIR)) {
console.error('membership-applications Verzeichnis nicht gefunden:', APPS_DIR)
process.exit(1)
}
const files = fs.readdirSync(APPS_DIR).filter(f => f.endsWith('.json'))
if (files.length === 0) {
console.log('Keine Bewerbungsdateien gefunden.')
return
}
let modified = 0
for (const file of files) {
const p = path.join(APPS_DIR, file)
let data
try {
data = JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (err) {
console.error('Fehler beim Lesen von', p, err.message)
continue
}
if ((!data.firstName || !data.lastName) && data.name) {
const { firstName, lastName } = extractNames(data.name)
data.firstName = data.firstName || firstName
data.lastName = data.lastName || lastName
// Backup
const backup = p + '.bak'
fs.copyFileSync(p, backup)
fs.writeFileSync(p, JSON.stringify(data, null, 2))
modified++
console.log('Updated', p, '-> backup at', backup)
}
}
console.log('Done. Modified files:', modified)
}
main()

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Script: split-names-in-users.js (ESM)
// Splittet das Feld "name" in firstName und lastName für alle User in users.json, falls noch nicht vorhanden.
// Backup wird automatisch angelegt.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const usersPath = path.join(__dirname, '../server/data/users.json')
const backupPath = usersPath + '.bak.' + new Date().toISOString().replace(/[:.]/g, '-')
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
function main() {
if (!fs.existsSync(usersPath)) {
console.error('users.json nicht gefunden:', usersPath)
process.exit(1)
}
const users = JSON.parse(fs.readFileSync(usersPath, 'utf8'))
let changed = false
for (const user of users) {
if ((!user.firstName || !user.lastName) && user.name) {
const { firstName, lastName } = extractNames(user.name)
if (!user.firstName) user.firstName = firstName
if (!user.lastName) user.lastName = lastName
changed = true
}
}
if (changed) {
fs.copyFileSync(usersPath, backupPath)
fs.writeFileSync(usersPath, JSON.stringify(users, null, 2))
console.log('Felder firstName/lastName ergänzt. Backup:', backupPath)
} else {
console.log('Keine Änderungen nötig. Alle Namen bereits gesplittet.')
}
}
main()

View File

@@ -0,0 +1,40 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getUserFromToken } from '../../utils/auth.js'
async function readPackageVersion() {
const cwd = process.cwd()
const candidatePaths = [
path.join(cwd, 'package.json'),
path.join(cwd, '../package.json')
]
for (const packageJsonPath of candidatePaths) {
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'))
if (packageJson?.version) {
return String(packageJson.version)
}
} catch (_error) {
// Try next candidate path (e.g. .output runtime)
}
}
return ''
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const user = token ? await getUserFromToken(token) : null
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Nicht authentifiziert'
})
}
return {
version: await readPackageVersion()
}
})

View File

@@ -133,7 +133,7 @@ export default defineEventHandler(async (event) => {
}) })
const optionsDuration = Date.now() - optionsStart const optionsDuration = Date.now() - optionsStart
console.log(`[DEBUG] Registration options generated (${optionsDuration}ms)`, { console.log('[DEBUG] Registration options generated', { optionsDurationMs: optionsDuration,
hasChallenge: !!options.challenge, hasChallenge: !!options.challenge,
challengeLength: options.challenge?.length, challengeLength: options.challenge?.length,
rpId: options.rp?.id, rpId: options.rp?.id,
@@ -185,7 +185,7 @@ export default defineEventHandler(async (event) => {
const totalDuration = Date.now() - requestStart const totalDuration = Date.now() - requestStart
// Debug: Prüfe die vollständige Options-Struktur // Debug: Prüfe die vollständige Options-Struktur
console.log(`[DEBUG] Returning options (total: ${totalDuration}ms)`, { console.log('[DEBUG] Returning options', { totalDurationMs: totalDuration,
registrationId, registrationId,
optionsKeys: Object.keys(options), optionsKeys: Object.keys(options),
challengeLength: options.challenge?.length, challengeLength: options.challenge?.length,

View File

@@ -161,7 +161,7 @@ export default defineEventHandler(async (event) => {
}) })
} catch (verifyError) { } catch (verifyError) {
const verifyDuration = Date.now() - verifyStart const verifyDuration = Date.now() - verifyStart
console.error(`[DEBUG] Verification error (${verifyDuration}ms):`, { console.error('[DEBUG] Verification error:', { verifyDurationMs: verifyDuration,
error: verifyError, error: verifyError,
message: verifyError?.message, message: verifyError?.message,
cause: verifyError?.cause?.message, cause: verifyError?.cause?.message,
@@ -175,7 +175,7 @@ export default defineEventHandler(async (event) => {
const verifyDuration = Date.now() - verifyStart const verifyDuration = Date.now() - verifyStart
const { verified, registrationInfo } = verification const { verified, registrationInfo } = verification
console.log(`[DEBUG] Verification completed (${verifyDuration}ms)`, { console.log('[DEBUG] Verification completed', { verifyDurationMs: verifyDuration,
verified, verified,
hasRegistrationInfo: !!registrationInfo, hasRegistrationInfo: !!registrationInfo,
credentialId: registrationInfo?.credentialID ? 'present' : 'missing', credentialId: registrationInfo?.credentialID ? 'present' : 'missing',
@@ -246,7 +246,7 @@ export default defineEventHandler(async (event) => {
await writeUsers(users) await writeUsers(users)
const totalDuration = Date.now() - requestStart const totalDuration = Date.now() - requestStart
console.log(`[DEBUG] User created successfully (total: ${totalDuration}ms)`, { console.log('[DEBUG] User created successfully', { totalDurationMs: totalDuration,
userId: newUser.id, userId: newUser.id,
email: newUser.email.substring(0, 10) + '...', email: newUser.email.substring(0, 10) + '...',
hasPasskey: newUser.passkeys?.length > 0, hasPasskey: newUser.passkeys?.length > 0,

View File

@@ -1,16 +1,16 @@
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js' import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
import nodemailer from 'nodemailer' import { sendRegistrationNotification } from '../../utils/email-service.js'
import { assertPasswordNotPwned } from '../../utils/hibp.js' import { assertPasswordNotPwned } from '../../utils/hibp.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const body = await readBody(event) const body = await readBody(event)
const { name, email, phone, password } = body const { name, email, phone, password, geburtsdatum, visibility } = body
if (!name || !email || !password) { if (!name || !email || !password || !geburtsdatum) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: 'Name, E-Mail und Passwort sind erforderlich' message: 'Name, E-Mail, Geburtsdatum und Passwort sind erforderlich'
}) })
} }
@@ -46,6 +46,10 @@ export default defineEventHandler(async (event) => {
password: hashedPassword, password: hashedPassword,
name, name,
phone: phone || '', phone: phone || '',
geburtsdatum,
visibility: {
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true
},
role: 'mitglied', role: 'mitglied',
active: false, // Requires admin approval active: false, // Requires admin approval
created: new Date().toISOString(), created: new Date().toISOString(),
@@ -55,61 +59,11 @@ export default defineEventHandler(async (event) => {
users.push(newUser) users.push(newUser)
await writeUsers(users) await writeUsers(users)
// Send notification email to admin // Send notification to Vorstand/admin via central email service
try { try {
const smtpUser = process.env.SMTP_USER await sendRegistrationNotification({ name, email, phone })
const smtpPass = process.env.SMTP_PASS
if (!smtpUser || !smtpPass) {
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
// Continue without sending email
} else {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: {
user: smtpUser,
pass: smtpPass
}
})
// Email to admin
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de',
subject: 'Neue Registrierung - Harheimer TC',
html: `
<h2>Neue Registrierung</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${name}</li>
<li><strong>E-Mail:</strong> ${email}</li>
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
})
// Email to user
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: email,
subject: 'Registrierung erhalten - Harheimer TC',
html: `
<h2>Registrierung erhalten</h2>
<p>Hallo ${name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
})
}
} catch (emailError) { } catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError) console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
// Continue anyway - user is registered
} }
return { return {

View File

@@ -0,0 +1,98 @@
import { readMembers, normalizeDate } from '../utils/members.js'
import { readUsers, migrateUserRoles, getUserFromToken, verifyToken } from '../utils/auth.js'
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
function getUpcomingBirthdays(entries, daysAhead = 28) {
const now = new Date()
const results = []
// iterate entries with geburtsdatum and name
for (const e of entries) {
const raw = e.geburtsdatum
if (!raw) continue
const parsed = new Date(raw)
if (isNaN(parsed.getTime())) continue
// Build next occurrence for this year
const thisYear = now.getFullYear()
const occ = new Date(thisYear, parsed.getMonth(), parsed.getDate())
// If already passed this year, consider next year
if (occ < now) {
occ.setFullYear(thisYear + 1)
}
const diffDays = Math.ceil((occ - now) / (1000 * 60 * 60 * 24))
if (diffDays >= 0 && diffDays <= daysAhead) {
results.push({
name: e.name || `${e.firstName || ''} ${e.lastName || ''}`.trim(),
dayMonth: `${String(occ.getDate()).padStart(2, '0')}.${String(occ.getMonth()+1).padStart(2, '0')}`,
date: occ,
diffDays
})
}
}
// Sort by upcoming date
results.sort((a, b) => a.date - b.date)
return results
}
export default defineEventHandler(async (event) => {
try {
// Determine viewer for visibility rules; token optional
const token = getCookie(event, 'auth_token')
let currentUser = null
if (token) {
const decoded = verifyToken(token)
if (decoded) {
currentUser = await getUserFromToken(token)
}
}
const manualMembers = await readMembers()
const registeredUsers = await readUsers()
// Build unified list of candidates with geburtsdatum and visibility
const candidates = []
for (const m of manualMembers) {
const normalizedStatus = m.status ? String(m.status).toLowerCase() : ''
const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== ''
const isAccepted = hasExplicitAcceptanceFlag
? (
m.active === true ||
m.accepted === true ||
normalizedStatus === 'accepted'
)
: true
if (!isAccepted) continue
const vis = m.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
}
for (const u of registeredUsers) {
if (!u.active) continue
const vis = u.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
}
// Respect visibility: if viewer is vorstand they see all birthdays
const isPrivilegedViewer = currentUser ? (Array.isArray(currentUser.roles) ? currentUser.roles.includes('vorstand') : currentUser.role === 'vorstand') : false
const filtered = candidates.filter(c => c.geburtsdatum && (isPrivilegedViewer || c.visibility.showBirthday === true))
const upcoming = getUpcomingBirthdays(filtered, 28)
// Return only next 4 weeks entries with name and dayMonth
return {
success: true,
birthdays: upcoming.map(b => ({ name: b.name, dayMonth: b.dayMonth, inDays: b.diffDays }))
}
} catch (error) {
console.error('Fehler beim Abrufen der Geburtstage:', error)
throw error
}
})

View File

@@ -0,0 +1,17 @@
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import { readContactRequests } from '../../utils/contact-requests.js'
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
const requests = await readContactRequests()
return requests
})

View File

@@ -0,0 +1,75 @@
import nodemailer from 'nodemailer'
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
function createTransporter() {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: { user: smtpUser, pass: smtpPass }
})
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
const body = await readBody(event)
const replyMessage = String(body?.message || '').trim()
if (!replyMessage) {
throw createError({ statusCode: 400, statusMessage: 'Antworttext fehlt' })
}
const requestId = getRouterParam(event, 'id')
if (!requestId) {
throw createError({ statusCode: 400, statusMessage: 'Anfrage-ID fehlt' })
}
const all = await readContactRequests()
const target = all.find((r) => r.id === requestId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
}
const transporter = createTransporter()
if (!transporter) {
throw createError({
statusCode: 500,
statusMessage: 'SMTP ist nicht konfiguriert'
})
}
const originalSubject = target.subject || 'Kontaktanfrage'
const responseSubject = `Aw: ${originalSubject}`
await transporter.sendMail({
from: `"Harheimer TC" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
to: target.email,
subject: responseSubject,
text: replyMessage
})
const responderEmail = currentUser.email || ''
const updated = await addContactReply({
requestId,
replyText: replyMessage,
responderEmail
})
return {
success: true,
request: updated
}
})

View File

@@ -0,0 +1,33 @@
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
import { readContactRequests, updateContactRequestStatus } from '../../../../utils/contact-requests.js'
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
const requestId = getRouterParam(event, 'id')
if (!requestId) {
throw createError({ statusCode: 400, statusMessage: 'Anfrage-ID fehlt' })
}
const all = await readContactRequests()
const target = all.find((r) => r.id === requestId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
}
const newStatus = target.status === 'beantwortet' ? 'offen' : 'beantwortet'
const updated = await updateContactRequestStatus(requestId, newStatus)
return {
success: true,
request: updated
}
})

View File

@@ -26,9 +26,12 @@ const getDataPath = (filename) => {
} }
// Multer-Konfiguration für PDF-Uploads // Multer-Konfiguration für PDF-Uploads
// Store uploads in internal data directory instead of public/
const DOCUMENTS_DIR = getDataPath('documents')
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
cb(null, 'public/documents/') cb(null, DOCUMENTS_DIR)
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
cb(null, 'satzung.pdf') cb(null, 'satzung.pdf')
@@ -74,8 +77,9 @@ export default defineEventHandler(async (event) => {
}) })
} }
try { try {
await fs.mkdir(path.join(process.cwd(), 'public', 'documents'), { recursive: true }) // Ensure internal documents dir exists
await fs.mkdir(DOCUMENTS_DIR, { recursive: true })
// Multer-Middleware für File-Upload // Multer-Middleware für File-Upload
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -133,8 +137,9 @@ export default defineEventHandler(async (event) => {
configData.seiten = {} configData.seiten = {}
} }
// Serve the uploaded statute via internal media proxy
configData.seiten.satzung = { configData.seiten.satzung = {
pdfUrl: '/documents/satzung.pdf', pdfUrl: '/api/media/documents/satzung.pdf',
content: htmlContent content: htmlContent
} }

View File

@@ -4,7 +4,15 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const token = getCookie(event, 'auth_token') let token = getCookie(event, 'auth_token')
if (!token) {
const authHeader = getHeader(event, 'authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7).trim()
}
}
const currentUser = token ? await getUserFromToken(token) : null const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser) { if (!currentUser) {
@@ -45,25 +53,12 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Wichtig: In Production werden statische Dateien aus `.output/public` ausgeliefert. // Neuer Ablauf (Option B): Schreibe CSVs ausschließlich in internes Datenverzeichnis,
// Wenn PM2 `cwd` auf das Repo-Root setzt, ist `process.cwd()` NICHT `.output` // damit keine direkten Schreibzugriffe auf `public/` stattfinden.
// daher schreiben wir robust in alle sinnvollen Zielorte: // Später kann ein kontrollierter Deploy-/Sync-Prozess die Daten aus `server/data/public-data`
// - `.output/public/data/<file>` (damit die laufende Instanz sofort die neuen Daten liefert) // in die öffentlich ausgelieferte `public/`-Location übernehmen.
// - `public/data/<file>` (damit der nächste Build die Daten wieder übernimmt)
//
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is validated against allowlist above, path traversal prevented
const cwd = process.cwd() const cwd = process.cwd()
const pathExists = async (p) => {
try {
await fs.access(p)
return true
} catch {
return false
}
}
const writeFileAtomicAndVerify = async (targetPath, data) => { const writeFileAtomicAndVerify = async (targetPath, data) => {
const dataDir = path.dirname(targetPath) const dataDir = path.dirname(targetPath)
await fs.mkdir(dataDir, { recursive: true }) await fs.mkdir(dataDir, { recursive: true })
@@ -88,56 +83,37 @@ export default defineEventHandler(async (event) => {
// ggf. inkonsistente Inhalte ausgeliefert (Browser meldet Partial Transfer). // ggf. inkonsistente Inhalte ausgeliefert (Browser meldet Partial Transfer).
// Daher: nach erfolgreichem Schreiben alte Varianten entfernen. // Daher: nach erfolgreichem Schreiben alte Varianten entfernen.
for (const ext of ['.gz', '.br']) { for (const ext of ['.gz', '.br']) {
try { await fs.unlink(`${targetPath}${ext}`) } catch (_e3) {} try { await fs.unlink(`${targetPath}${ext}`) } catch (_e3) { /* no-op */ }
} }
} catch (e) { } catch (e) {
// best-effort cleanup // best-effort cleanup
try { await fs.unlink(tmpPath) } catch (_e2) {} try { await fs.unlink(tmpPath) } catch (_e2) { /* no-op */ }
throw e throw e
} }
} }
// Preferred: das tatsächlich ausgelieferte Verzeichnis in Production // Ziel: internes Datenverzeichnis unter `server/data/public-data` (persistente, interne Quelle)
// (Nuxt/Nitro serve static aus `.output/public`) const dataTargetsByFile = {
const preferredPaths = [] 'vereinsmeisterschaften.csv': [`${cwd}/server/data/public-data/vereinsmeisterschaften.csv`, `${cwd}/../server/data/public-data/vereinsmeisterschaften.csv`],
if (await pathExists(path.join(cwd, '.output/public'))) { 'mannschaften.csv': [`${cwd}/server/data/public-data/mannschaften.csv`, `${cwd}/../server/data/public-data/mannschaften.csv`],
preferredPaths.push(path.join(cwd, '.output/public/data', filename)) 'termine.csv': [`${cwd}/server/data/public-data/termine.csv`, `${cwd}/../server/data/public-data/termine.csv`],
} 'spielplan.csv': [`${cwd}/server/data/public-data/spielplan.csv`, `${cwd}/../server/data/public-data/spielplan.csv`]
if (await pathExists(path.join(cwd, '../.output/public'))) {
preferredPaths.push(path.join(cwd, '../.output/public/data', filename))
} }
const internalPaths = dataTargetsByFile[filename] || []
// Fallbacks: Source-Public (für Persistenz bei nächstem Build) und diverse cwd-Layouts const uniquePaths = [...new Set([...internalPaths])]
const fallbackPaths = [
path.join(cwd, 'public/data', filename),
path.join(cwd, '../public/data', filename)
]
const uniquePaths = [...new Set([...preferredPaths, ...fallbackPaths])]
const writeResults = [] const writeResults = []
const writeErrors = [] const writeErrors = []
let wrotePreferred = false
for (const targetPath of uniquePaths) { for (const targetPath of uniquePaths) {
try { try {
await writeFileAtomicAndVerify(targetPath, content) await writeFileAtomicAndVerify(targetPath, content)
writeResults.push(targetPath) writeResults.push(targetPath)
if (preferredPaths.includes(targetPath)) wrotePreferred = true
} catch (e) { } catch (e) {
writeErrors.push({ targetPath, error: e?.message || String(e) }) writeErrors.push({ targetPath, error: e?.message || String(e) })
} }
} }
// Wenn wir ein `.output/public` gefunden haben, MUSS auch dorthin geschrieben worden sein.
// Sonst melden wir nicht "Erfolg", weil die laufende Instanz dann weiterhin alte/defekte Daten ausliefert.
if (preferredPaths.length > 0 && !wrotePreferred) {
console.error('CSV wurde NICHT in .output/public geschrieben. Errors:', writeErrors)
throw createError({
statusCode: 500,
statusMessage: 'CSV konnte nicht in das ausgelieferte Verzeichnis geschrieben werden'
})
}
if (writeResults.length === 0) { if (writeResults.length === 0) {
console.error('Konnte CSV-Datei in keinen Zielpfad schreiben:', writeErrors) console.error('Konnte CSV-Datei in keinen Zielpfad schreiben:', writeErrors)
throw createError({ throw createError({

View File

@@ -17,25 +17,32 @@ export default defineEventHandler(async (event) => {
const isVorstand = hasRole(currentUser, 'vorstand') const isVorstand = hasRole(currentUser, 'vorstand')
// Return users without Passwörter; Kontaktdaten nur für Vorstand // Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
const safeUsers = users.map(u => { const safeUsers = users.map(u => {
const migrated = migrateUserRoles({ ...u }) const migrated = migrateUserRoles({ ...u })
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied']) const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
const email = isVorstand ? u.email : undefined return canSeePrivate
const phone = isVorstand ? (u.phone || '') : undefined ? {
id: u.id,
return { email: u.email,
id: u.id, name: u.name,
email, roles: roles,
name: u.name, role: roles[0] || 'mitglied',
roles: roles, phone: u.phone || '',
role: roles[0] || 'mitglied', // Rückwärtskompatibilität active: u.active,
phone, created: u.created,
active: u.active, lastLogin: u.lastLogin
created: u.created, }
lastLogin: u.lastLogin : {
} id: u.id,
name: u.name,
role: roles[0] || 'mitglied',
active: u.active,
lastLogin: u.lastLogin
}
}) })
return { return {

View File

@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
const { userId, roles } = body const { userId, roles } = body
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter'] const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter', 'trainer']
const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied']) const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied'])
if (!rolesArray.every(r => validRoles.includes(r))) { if (!rolesArray.every(r => validRoles.includes(r))) {

View File

@@ -1,10 +1,99 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { promises as fs } from 'fs'
import path from 'path'
import { createContactRequest } from '../utils/contact-requests.js'
import { readUsers, migrateUserRoles } from '../utils/auth.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant ('config.json'), never user input
const getConfigPath = () => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) return `${cwd}/../server/data/config.json`
return `${cwd}/server/data/config.json`
}
async function loadConfig() {
try {
const configFile = getConfigPath()
const raw = await fs.readFile(configFile, 'utf-8')
return JSON.parse(raw)
} catch (error) {
console.error('Fehler beim Laden der Konfiguration für Kontaktanfragen:', error)
return {}
}
}
async function collectRecipients(config) {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
if (!isProduction) {
return ['tsschulz@tsschulz.de']
}
const recipients = []
// Vorstand
if (config?.vorstand && typeof config.vorstand === 'object') {
for (const member of Object.values(config.vorstand)) {
if (member?.email && typeof member.email === 'string' && member.email.trim()) {
recipients.push(member.email.trim())
}
}
}
// Trainer
if (Array.isArray(config?.trainer)) {
for (const trainer of config.trainer) {
if (trainer?.email && typeof trainer.email === 'string' && trainer.email.trim()) {
recipients.push(trainer.email.trim())
}
}
}
// Zusätzlich: Benutzer mit Trainer-Rolle aus dem Login-System
try {
const users = await readUsers()
for (const rawUser of users) {
const user = migrateUserRoles({ ...rawUser })
const roles = Array.isArray(user.roles) ? user.roles : []
if (roles.includes('trainer') && user.email && String(user.email).trim()) {
recipients.push(String(user.email).trim())
}
}
} catch (error) {
console.error('Fehler beim Laden der Trainer-Empfänger aus Benutzerdaten:', error)
}
const unique = [...new Set(recipients)]
if (unique.length > 0) return unique
// Fallback
if (config?.website?.verantwortlicher?.email) {
return [config.website.verantwortlicher.email]
}
if (process.env.SMTP_USER) {
return [process.env.SMTP_USER]
}
return ['j.dichmann@gmx.de']
}
function createTransporter() {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: { user: smtpUser, pass: smtpPass }
})
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const body = await readBody(event) const body = await readBody(event)
// Validierung der Eingabedaten
if (!body.name || !body.email || !body.subject || !body.message) { if (!body.name || !body.email || !body.subject || !body.message) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
@@ -12,7 +101,6 @@ export default defineEventHandler(async (event) => {
}) })
} }
// E-Mail-Validierung
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) { if (!emailRegex.test(body.email)) {
throw createError({ throw createError({
@@ -21,34 +109,32 @@ export default defineEventHandler(async (event) => {
}) })
} }
// SMTP-Konfiguration (hier können Sie Ihre SMTP-Daten eintragen) // Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
const smtpUser = process.env.SMTP_USER || 'j.dichmann@gmx.de' await createContactRequest({
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD name: String(body.name).trim(),
email: String(body.email).trim(),
if (!smtpUser || !smtpPass) { phone: body.phone ? String(body.phone).trim() : '',
throw createError({ subject: String(body.subject).trim(),
statusCode: 500, message: String(body.message).trim()
statusMessage: 'SMTP-Credentials fehlen! Bitte setzen Sie SMTP_USER und SMTP_PASS in der .env Datei.'
})
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false, // true für 465, false für andere Ports
auth: {
user: smtpUser,
pass: smtpPass
}
}) })
// E-Mail-Template const config = await loadConfig()
const recipients = await collectRecipients(config)
const transporter = createTransporter()
if (!transporter) {
return {
success: true,
message: 'Anfrage wurde gespeichert. E-Mail-Versand ist aktuell nicht konfiguriert.'
}
}
const nowLabel = new Date().toLocaleString('de-DE')
const emailHtml = ` const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #dc2626; border-bottom: 2px solid #dc2626; padding-bottom: 10px;"> <h2 style="color: #dc2626; border-bottom: 2px solid #dc2626; padding-bottom: 10px;">
Neue Kontaktanfrage - Harheimer TC Neue Kontaktanfrage - Harheimer TC
</h2> </h2>
<div style="background-color: #f9fafb; padding: 20px; border-radius: 8px; margin: 20px 0;"> <div style="background-color: #f9fafb; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="color: #374151; margin-top: 0;">Kontaktdaten:</h3> <h3 style="color: #374151; margin-top: 0;">Kontaktdaten:</h3>
<p><strong>Name:</strong> ${body.name}</p> <p><strong>Name:</strong> ${body.name}</p>
@@ -56,21 +142,18 @@ export default defineEventHandler(async (event) => {
<p><strong>Telefon:</strong> ${body.phone || 'Nicht angegeben'}</p> <p><strong>Telefon:</strong> ${body.phone || 'Nicht angegeben'}</p>
<p><strong>Betreff:</strong> ${body.subject}</p> <p><strong>Betreff:</strong> ${body.subject}</p>
</div> </div>
<div style="background-color: #ffffff; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;"> <div style="background-color: #ffffff; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h3 style="color: #374151; margin-top: 0;">Nachricht:</h3> <h3 style="color: #374151; margin-top: 0;">Nachricht:</h3>
<p style="white-space: pre-wrap; line-height: 1.6;">${body.message}</p> <p style="white-space: pre-wrap; line-height: 1.6;">${body.message}</p>
</div> </div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;"> <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
<p>Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.</p> <p>Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.</p>
<p>Zeitstempel: ${new Date().toLocaleString('de-DE')}</p> <p>Zeitstempel: ${nowLabel}</p>
</div> </div>
</div> </div>
` `
const emailText = ` const emailText = `Neue Kontaktanfrage - Harheimer TC
Neue Kontaktanfrage - Harheimer TC
Kontaktdaten: Kontaktdaten:
Name: ${body.name} Name: ${body.name}
@@ -83,36 +166,29 @@ ${body.message}
--- ---
Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet. Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.
Zeitstempel: ${new Date().toLocaleString('de-DE')} Zeitstempel: ${nowLabel}`
`
// E-Mail senden await transporter.sendMail({
const mailOptions = { from: `"Harheimer TC Website" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
from: `"Harheimer TC Website" <${process.env.SMTP_USER || 'j.dichmann@gmx.de'}>`, to: recipients.join(', '),
to: 'j.dichmann@gmx.de',
replyTo: body.email, replyTo: body.email,
subject: `Kontaktanfrage: ${body.subject}`, subject: `Kontaktanfrage: ${body.subject}`,
text: emailText, text: emailText,
html: emailHtml html: emailHtml
} })
await transporter.sendMail(mailOptions)
return { return {
success: true, success: true,
message: 'E-Mail wurde erfolgreich gesendet!' message: 'Anfrage wurde erfolgreich gesendet.'
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Senden der E-Mail:', error) console.error('Fehler bei Kontaktanfrage:', error)
if (error.statusCode) { if (error.statusCode) throw error
throw error
}
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Fehler beim Senden der E-Mail. Bitte versuchen Sie es später erneut.' statusMessage: 'Fehler beim Senden der Anfrage. Bitte versuchen Sie es später erneut.'
}) })
} }
}) })

View File

@@ -45,35 +45,49 @@ export default defineEventHandler(async (event) => {
} }
} }
const metadata = await readGalerieMetadata() let metadata = []
try {
metadata = await readGalerieMetadata()
if (!Array.isArray(metadata)) {
console.warn('Galerie-Metadaten haben unerwartetes Format, verwende leere Liste')
metadata = []
}
} catch (e) {
console.error('Fehler beim Lesen der Galerie-Metadaten, liefere leeres Ergebnis:', e.message)
metadata = []
}
// Filtere Bilder basierend auf Sichtbarkeit // Filtere Bilder basierend auf Sichtbarkeit
const visibleImages = metadata.filter(image => { const visibleImages = metadata.filter(image => {
// Öffentliche Bilder sind für alle sichtbar // Defensive checks
if (!image || typeof image !== 'object') return false
if (image.isPublic) return true if (image.isPublic) return true
// Private Bilder nur für eingeloggte Mitglieder
return isLoggedIn return isLoggedIn
}) })
// Sortiere nach Upload-Datum (neueste zuerst) // Sortiere nach Upload-Datum (neueste zuerst) - defensive
visibleImages.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt)) visibleImages.sort((a, b) => {
const ta = new Date(a.uploadedAt || 0).getTime()
const tb = new Date(b.uploadedAt || 0).getTime()
return tb - ta
})
// Pagination // Pagination (defensive defaults)
const page = parseInt(getQuery(event).page) || 1 const page = Math.max(1, parseInt(getQuery(event).page) || 1)
const perPage = 10 const perPage = Math.max(1, parseInt(getQuery(event).perPage) || 10)
const start = (page - 1) * perPage const start = (page - 1) * perPage
const end = start + perPage const paginatedImages = visibleImages.slice(start, start + perPage)
const paginatedImages = visibleImages.slice(start, end)
// Konsistente Rückgabeform
return { return {
success: true, success: true,
images: paginatedImages.map(img => ({ images: paginatedImages.map(img => ({
id: img.id, id: img.id || img.filename || null,
title: img.title, title: img.title || '',
description: img.description, description: img.description || '',
isPublic: img.isPublic, isPublic: !!img.isPublic,
uploadedAt: img.uploadedAt, uploadedAt: img.uploadedAt || null,
previewFilename: img.previewFilename previewFilename: img.previewFilename || null
})), })),
pagination: { pagination: {
page, page,

View File

@@ -15,7 +15,13 @@ export default defineEventHandler(async (event) => {
const cwd = process.cwd() const cwd = process.cwd()
const filename = 'mannschaften.csv' const filename = 'mannschaften.csv'
// Prefer CMS write target first (server/data/public-data),
// then legacy locations.
const candidates = [ const candidates = [
path.join(cwd, 'server/data/public-data', filename),
path.join(cwd, '../server/data/public-data', filename),
path.join(cwd, '.output/server/data', filename),
path.join(cwd, 'server/data', filename),
path.join(cwd, '.output/public/data', filename), path.join(cwd, '.output/public/data', filename),
path.join(cwd, 'public/data', filename), path.join(cwd, 'public/data', filename),
path.join(cwd, '../.output/public/data', filename), path.join(cwd, '../.output/public/data', filename),
@@ -24,7 +30,7 @@ export default defineEventHandler(async (event) => {
let csvPath = null let csvPath = null
for (const p of candidates) { for (const p of candidates) {
// eslint-disable-next-line no-await-in-loop
if (await exists(p)) { if (await exists(p)) {
csvPath = p csvPath = p
break break

View File

@@ -2,14 +2,14 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getUserFromToken, verifyToken } from '../../../utils/auth.js' import { getUserFromToken, verifyToken } from '../../../utils/auth.js'
const getDataPath = (filename) => { const getDataRoot = () => {
const cwd = process.cwd() const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename) return cwd.endsWith('.output') ? `${cwd}/../server/data` : `${cwd}/server/data`
return path.join(cwd, 'server/data', filename)
} }
const GALERIE_DIR = getDataPath('galerie') const DATA_ROOT = getDataRoot()
const GALERIE_METADATA = getDataPath('galerie-metadata.json') const GALERIE_DIR = `${DATA_ROOT}/galerie`
const GALERIE_METADATA = `${DATA_ROOT}/galerie-metadata.json`
async function readGalerieMetadata() { async function readGalerieMetadata() {
try { try {

View File

@@ -35,8 +35,9 @@ export default defineEventHandler(async (event) => {
const filePath = resolveInternalPath(reqPath) const filePath = resolveInternalPath(reqPath)
// check existence and ensure it stays within baseDir // check existence and ensure it stays within baseDir
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal') const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
const resolved = path.resolve(filePath) const resolved = path.normalize(filePath)
if (!resolved.startsWith(path.resolve(baseDir))) { const normalizedBaseDir = path.normalize(baseDir + path.sep)
if (!resolved.startsWith(normalizedBaseDir)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Pfad' }) throw createError({ statusCode: 400, statusMessage: 'Ungültiger Pfad' })
} }

View File

@@ -22,11 +22,11 @@ export default defineEventHandler(async (event) => {
}) })
} }
const currentUser = await getUserFromToken(token) const currentUser = await getUserFromToken(token)
// Get manual members and registered users // Get manual members and registered users
const manualMembers = await readMembers() const manualMembers = await readMembers()
const registeredUsers = await readUsers() const registeredUsers = await readUsers()
// Merge members: combine manual + registered, detect duplicates // Merge members: combine manual + registered, detect duplicates
const mergedMembers = [] const mergedMembers = []
@@ -35,14 +35,38 @@ export default defineEventHandler(async (event) => {
const emailToIndexMap = new Map() // email -> index in mergedMembers const emailToIndexMap = new Map() // email -> index in mergedMembers
const nameToIndexMap = new Map() // name -> index in mergedMembers const nameToIndexMap = new Map() // name -> index in mergedMembers
// First, add all manual members and build lookup maps // First, add manual members. Legacy records without explicit status flags
// are treated as accepted members; only explicit non-accepted records are skipped.
for (let i = 0; i < manualMembers.length; i++) { for (let i = 0; i < manualMembers.length; i++) {
const member = manualMembers[i] const member = manualMembers[i]
const normalizedStatus = member.status ? String(member.status).toLowerCase() : ''
const hasExplicitAcceptanceFlag = member.active !== undefined || member.accepted !== undefined || normalizedStatus !== ''
const isAccepted = hasExplicitAcceptanceFlag
? (
member.active === true ||
member.accepted === true ||
normalizedStatus === 'accepted'
)
: true
if (!isAccepted) {
// Skip applications that are not yet accepted
continue
}
const normalizedEmail = member.email?.toLowerCase().trim() || '' const normalizedEmail = member.email?.toLowerCase().trim() || ''
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim() const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
const normalizedName = fullName.toLowerCase() const normalizedName = fullName.toLowerCase()
const memberIndex = mergedMembers.length const memberIndex = mergedMembers.length
// Ensure visibility flags are booleans for manual entries
const vis = member.visibility || {}
member.visibility = {
// Default: visible to all logged-in members unless explicitly hidden
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
// Address remains private by default
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
}
mergedMembers.push({ mergedMembers.push({
...member, ...member,
name: fullName, // Computed for display name: fullName, // Computed for display
@@ -66,13 +90,21 @@ export default defineEventHandler(async (event) => {
} }
} }
// Then add registered users (only active ones) // Then add registered users (only active ones)
for (const user of registeredUsers) { for (const user of registeredUsers) {
if (!user.active) continue if (!user.active) continue
const normalizedEmail = user.email?.toLowerCase().trim() || '' const normalizedEmail = user.email?.toLowerCase().trim() || ''
const normalizedName = user.name?.toLowerCase().trim() || '' const normalizedName = user.name?.toLowerCase().trim() || ''
// Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
// Check if this user matches an existing manual member using O(1) lookup // Check if this user matches an existing manual member using O(1) lookup
let matchedManualIndex = -1 let matchedManualIndex = -1
@@ -108,6 +140,8 @@ export default defineEventHandler(async (event) => {
// Merge with existing manual member // Merge with existing manual member
const migratedUser = migrateUserRoles({ ...user }) const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied']) const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Extrahiere Namen nur, wenn Felder leer sind
const { firstName, lastName } = extractNames(user.name)
mergedMembers[matchedManualIndex] = { mergedMembers[matchedManualIndex] = {
...mergedMembers[matchedManualIndex], ...mergedMembers[matchedManualIndex],
hasLogin: true, hasLogin: true,
@@ -115,21 +149,46 @@ export default defineEventHandler(async (event) => {
loginRoles: roles, loginRoles: roles,
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
lastLogin: user.lastLogin, lastLogin: user.lastLogin,
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true,
geburtsdatum: mergedMembers[matchedManualIndex].geburtsdatum || user.geburtsdatum || '',
firstName: mergedMembers[matchedManualIndex].firstName || firstName,
lastName: mergedMembers[matchedManualIndex].lastName || lastName,
editable: true
}
// If the registered user has visibility preferences, apply them (coerce to booleans)
if (user.visibility && typeof user.visibility === 'object') {
const vis = mergedMembers[matchedManualIndex].visibility || {}
mergedMembers[matchedManualIndex].visibility = {
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone),
showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress)
}
} }
} else { } else {
// Add as new member (from login system) // Add as new member (from login system)
const migratedUser = migrateUserRoles({ ...user }) const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied']) const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Registered-only user: default to privacy-preserving visibility (hidden) unless user explicitly set visibility elsewhere
// Use stored visibility from user if present, otherwise default to false
const userVis = user.visibility || {}
const { firstName, lastName } = extractNames(user.name)
mergedMembers.push({ mergedMembers.push({
id: user.id, id: user.id,
name: user.name, name: user.name,
firstName,
lastName,
geburtsdatum: user.geburtsdatum || '',
email: user.email, email: user.email,
phone: user.phone || '', phone: user.phone || '',
address: '', address: '',
visibility: {
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone),
showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress)
},
notes: `Rolle(n): ${roles.join(', ')}`, notes: `Rolle(n): ${roles.join(', ')}`,
source: 'login', source: 'login',
editable: false, editable: true,
hasLogin: true, hasLogin: true,
loginEmail: user.email, loginEmail: user.email,
loginRoles: roles, loginRoles: roles,
@@ -142,21 +201,76 @@ export default defineEventHandler(async (event) => {
// Sort by name // Sort by name
mergedMembers.sort((a, b) => a.name.localeCompare(b.name)) mergedMembers.sort((a, b) => a.name.localeCompare(b.name))
// Die Mitgliederliste ist nur für authentifizierte Nutzer sichtbar (siehe oben).
// Respektiere individuelle Sichtbarkeitspräferenzen (user.visibility)
const currentUserToken = token
const isViewerAuthenticated = !!currentUser
// Only 'vorstand' may override member visibility
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
// Serverseitiger Datenschutz: Kontaktdaten nur für Vorstand // Filtere den Admin-Account heraus
const isVorstand = hasRole(currentUser, 'vorstand') const filteredMembers = mergedMembers.filter(m => m.email?.toLowerCase() !== 'admin@harheimertc.de')
const safeMembers = isVorstand const sanitizedMembers = filteredMembers.map(member => {
? mergedMembers // Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them
: mergedMembers.map(m => ({ const visibility = member.visibility || {}
...m,
email: undefined, const showEmail = visibility.showEmail === undefined ? true : Boolean(visibility.showEmail)
phone: undefined, const showPhone = visibility.showPhone === undefined ? true : Boolean(visibility.showPhone)
address: undefined const showAddress = visibility.showAddress === undefined ? false : Boolean(visibility.showAddress)
}))
// Determine if contact info existed but was hidden to the viewer
const hadEmail = !!member.email
const hadPhone = !!member.phone
const hadAddress = !!member.address
const hadBirthday = !!member.geburtsdatum
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true)))
const contactHidden = (!emailVisible && hadEmail) || (!phoneVisible && hadPhone) || (!addressVisible && hadAddress)
return {
id: member.id,
name: member.name,
firstName: member.firstName || '',
lastName: member.lastName || '',
source: member.source,
editable: member.editable,
hasLogin: member.hasLogin,
loginRoles: member.loginRoles,
loginRole: member.loginRole,
lastLogin: member.lastLogin,
isMannschaftsspieler: member.isMannschaftsspieler,
hasHallKey: member.hasHallKey === true || member.hasHallenschluessel === true,
notes: member.notes || '',
// Sichtbarkeits-Flags explizit mitgeben
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress),
showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday),
// Privileged viewers (vorstand) always see contact fields
email: emailVisible ? member.email : undefined,
phone: phoneVisible ? member.phone : undefined,
address: addressVisible ? member.address : undefined,
// Birthday: expose only day + month and only if allowed; do not expose year or age
birthday: (birthdayVisible && hadBirthday) ? (function(){
try {
const d = new Date(member.geburtsdatum)
if (isNaN(d.getTime())) return undefined
const day = `${d.getDate()}`.padStart(2, '0')
const month = `${d.getMonth()+1}`.padStart(2, '0')
return `${day}.${month}`
} catch (_e) {
return undefined
}
})() : undefined,
geburtsdatum: member.geburtsdatum || undefined // Originalfeld für das Edit-Formular
}
})
return { return {
success: true, success: true,
members: safeMembers members: sanitizedMembers
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Abrufen der Mitgliederliste:', error) console.error('Fehler beim Abrufen der Mitgliederliste:', error)

View File

@@ -1,4 +1,4 @@
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { getUserFromToken, hasAnyRole } from '../utils/auth.js'
import { saveMember } from '../utils/members.js' import { saveMember } from '../utils/members.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -21,21 +21,12 @@ export default defineEventHandler(async (event) => {
}) })
} }
const decoded = verifyToken(token) const user = await getUserFromToken(token)
if (!decoded) {
throw createError({
statusCode: 401,
message: 'Ungültiges Token.'
})
}
const user = await getUserById(decoded.id)
if (!user) { if (!user) {
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
message: 'Benutzer nicht gefunden.' message: 'Nicht authentifiziert oder Benutzer nicht gefunden.'
}) })
} }
@@ -47,8 +38,8 @@ export default defineEventHandler(async (event) => {
}) })
} }
const body = await readBody(event) const body = await readBody(event)
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler } = body const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active } = body
if (!firstName || !lastName) { if (!firstName || !lastName) {
throw createError({ throw createError({
@@ -57,10 +48,10 @@ export default defineEventHandler(async (event) => {
}) })
} }
if (!geburtsdatum) { if (!geburtsdatum && !id) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: 'Geburtsdatum ist erforderlich, um Duplikate zu vermeiden.' message: 'Geburtsdatum ist für neue Mitglieder erforderlich, um Duplikate zu vermeiden.'
}) })
} }
@@ -74,7 +65,9 @@ export default defineEventHandler(async (event) => {
phone: phone || '', phone: phone || '',
address: address || '', address: address || '',
notes: notes || '', notes: notes || '',
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true' isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
active: typeof active === 'boolean' ? active : true
}) })
return { return {

View File

@@ -4,6 +4,13 @@ import { decryptObject } from '../../utils/encryption.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// Nur Vorstand oder Admin darf Mitgliedschaftsantraege lesen
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({ statusCode: 403, statusMessage: 'Zugriff verweigert' })
}
const config = useRuntimeConfig() const config = useRuntimeConfig()
const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production' const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production'
@@ -73,7 +80,7 @@ export default defineEventHandler(async (event) => {
// Nach Zeitstempel sortieren (neueste zuerst) // Nach Zeitstempel sortieren (neueste zuerst)
applications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) applications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
return applications return applications
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Mitgliedschaftsanträge:', error) console.error('Fehler beim Laden der Mitgliedschaftsanträge:', error)

View File

@@ -1,6 +1,7 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getUserFromToken } from '../../../utils/auth.js' import { getUserFromToken } from '../../../utils/auth.js'
import { getServerDataPath } from '../../../utils/paths.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
} }
// Upload-Verzeichnis finden (intern) // Upload-Verzeichnis finden (intern)
const uploadDir = path.join(process.cwd(), '..', 'server', 'data', 'uploads') const uploadDir = getServerDataPath('uploads')
console.log('Upload-Verzeichnis:', uploadDir) console.log('Upload-Verzeichnis:', uploadDir)
// Alle Dateien im Upload-Verzeichnis durchsuchen // Alle Dateien im Upload-Verzeichnis durchsuchen

View File

@@ -3,8 +3,10 @@ import { exec } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { StandardFonts } from 'pdf-lib' import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js' import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js'
import { sendMembershipEmail as sendMembershipEmailUtil } from '../../utils/email-service.js'
import { getProjectPath, getServerDataPath } from '../../utils/paths.js'
// const require = createRequire(import.meta.url) // Nicht verwendet // const require = createRequire(import.meta.url) // Nicht verwendet
const execAsync = promisify(exec) const execAsync = promisify(exec)
@@ -98,7 +100,7 @@ function generateLaTeXContent(data) {
// LaTeX-Inhalt mit korrekten Escapes generieren // LaTeX-Inhalt mit korrekten Escapes generieren
let latexContent = `\\documentclass[12pt,a4paper]{article} let latexContent = `\\documentclass[12pt,a4paper]{article}
\\usepackage[utf8]{inputenc} \\usepackage[utf8]{inputenc}
\\usepackage[ngerman]{babel} \\usepackage[german]{babel}
\\usepackage{geometry} \\usepackage{geometry}
\\usepackage{enumitem} \\usepackage{enumitem}
\\usepackage{xcolor} \\usepackage{xcolor}
@@ -305,71 +307,11 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
return `${filename}.txt` return `${filename}.txt`
} }
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'membership-applications'), never user input
function getDataPath(filename) { function getDataPath(filename) {
// Immer den absoluten Pfad zum Projekt-Root verwenden return getServerDataPath(filename)
// In der Entwicklung: process.cwd() ist bereits das Projekt-Root
// In der Produktion: process.cwd() ist .output, daher ein Verzeichnis zurück
const isDev = process.env.NODE_ENV === 'development'
const projectRoot = isDev ? process.cwd() : path.resolve(process.cwd(), '..')
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(projectRoot, 'server', 'data', filename)
} }
async function sendMembershipEmail(data, _filename, _event) { // Use central email service
try {
const configPath = getDataPath('config.json')
const configData = await fs.readFile(configPath, 'utf8')
const config = JSON.parse(configData)
let recipients = []
let subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
// Sammle alle verfügbaren E-Mail-Adressen
const availableEmails = []
// Vorsitzender E-Mail hinzufügen (falls vorhanden)
if (config.vorstand.vorsitzender.email && config.vorstand.vorsitzender.email.trim() !== '') {
availableEmails.push(config.vorstand.vorsitzender.email)
}
// Schriftführer E-Mail hinzufügen (falls vorhanden)
if (config.vorstand.schriftfuehrer.email && config.vorstand.schriftfuehrer.email.trim() !== '') {
availableEmails.push(config.vorstand.schriftfuehrer.email)
}
// Fallback: Wenn keine E-Mails verfügbar sind, verwende tsschulz@tsschulz.de
if (availableEmails.length === 0) {
recipients = ['tsschulz@tsschulz.de']
} else {
recipients = availableEmails
}
// In nicht-Produktionsumgebung: Alle E-Mails an tsschulz@tsschulz.de
if (process.env.NODE_ENV !== 'production') {
recipients = ['tsschulz@tsschulz.de']
}
const message = `Ein neuer Mitgliedschaftsantrag wurde eingereicht.
Antragsteller: ${data.vorname} ${data.nachname}
Mitgliedschaftsart: ${data.mitgliedschaftsart}
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
// E-Mail-Versand implementieren (hier würde normalerweise nodemailer verwendet)
console.log('E-Mail würde gesendet werden an:', recipients)
console.log('Betreff:', subject)
console.log('Nachricht:', message)
return { success: true, recipients, subject, message }
} catch (error) {
console.error('Fehler beim Senden der E-Mail:', error)
return { success: false, error: error.message }
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -402,17 +344,21 @@ export default defineEventHandler(async (event) => {
const timestamp = Date.now() const timestamp = Date.now()
const filename = `beitrittserklärung_${timestamp}` const filename = `beitrittserklärung_${timestamp}`
// Temp-Verzeichnis erstellen // Temp-Verzeichnis erstellen (bewusst außerhalb von .output,
const tempDir = path.join(process.cwd(), '.output', 'temp', 'latex') // da Deploy-Artefakte dort je nach Setup schreibgeschützt sein können)
const tempDir = getServerDataPath('tmp', 'latex')
await fs.mkdir(tempDir, { recursive: true }) await fs.mkdir(tempDir, { recursive: true })
const uploadsDir = getDataPath('uploads')
await fs.mkdir(uploadsDir, { recursive: true })
let finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
try { try {
// PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen // PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen // Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
async function fillPdfTemplate(data) { async function fillPdfTemplate(data) {
// Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template // Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template
const fillablePath = path.join(process.cwd(), 'server', 'templates', 'mitgliedschaft-fillable.pdf') const fillablePath = getProjectPath('server', 'templates', 'mitgliedschaft-fillable.pdf')
const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf') const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || getProjectPath('server', 'templates', 'Aufnahmeantrag 2025.pdf')
let arrayBuffer let arrayBuffer
try { try {
const localExists = await fs.stat(localPath).then(() => true).catch(() => false) const localExists = await fs.stat(localPath).then(() => true).catch(() => false)
@@ -425,8 +371,8 @@ export default defineEventHandler(async (event) => {
if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`) if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`)
arrayBuffer = await res.arrayBuffer() arrayBuffer = await res.arrayBuffer()
} }
} catch (_e) { } catch (templateLoadError) {
throw new Error('Template-Laden fehlgeschlagen: ' + e.message) throw new Error('Template-Laden fehlgeschlagen: ' + templateLoadError.message)
} }
const pdfDoc = await PDFDocument.load(arrayBuffer) const pdfDoc = await PDFDocument.load(arrayBuffer)
@@ -442,7 +388,7 @@ export default defineEventHandler(async (event) => {
// Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden. // Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden.
const pages = pdfDoc.getPages() const pages = pdfDoc.getPages()
const firstPage = pages[0] const firstPage = pages[0]
firstPage.getSize() const { height } = firstPage.getSize()
// Schätzwerte: (x, y) in Punkten von linker unteren Ecke // Schätzwerte: (x, y) in Punkten von linker unteren Ecke
// Diese Werte müssen nach Sichtprüfung justiert werden. // Diese Werte müssen nach Sichtprüfung justiert werden.
@@ -480,22 +426,8 @@ export default defineEventHandler(async (event) => {
bank: { x: leftX, y: baseY - gap * 3 + yOffset } bank: { x: leftX, y: baseY - gap * 3 + yOffset }
} }
const drawText = (page, text, x, y, size = 11) => {
page.drawText(text || '', {
x,
y,
size,
font: pdfDoc.embedStandardFont ? undefined : undefined,
// default black
color: undefined
})
}
// Einbettung der Standard-Schrift (Helvetica) // Einbettung der Standard-Schrift (Helvetica)
const helveticaFont = await pdfDoc.embedFont(PDFDocument.PDFName ? 'Helvetica' : 'Helvetica') const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
// NOTE: pdf-lib's embedFont usage above uses embedFont(fontBytes) in normal case;
// to keep it simple we attempt to embed built-in font via embedFont(StandardFonts)
// Fallback: drawText will work with default font if embed fails.
// Zeichne die Felder // Zeichne die Felder
try { try {
@@ -522,8 +454,8 @@ export default defineEventHandler(async (event) => {
} else if (data.mitgliedschaftsart === 'passiv') { } else if (data.mitgliedschaftsart === 'passiv') {
firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont }) firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont })
} }
} catch (_e) { } catch (checkboxError) {
console.warn('Fehler beim Zeichnen der Checkbox:', e.message) console.warn('Fehler beim Zeichnen der Checkbox:', checkboxError.message)
} }
// Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true // Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true
if (data && data.debug) { if (data && data.debug) {
@@ -542,12 +474,12 @@ export default defineEventHandler(async (event) => {
// small label a bit to the right // small label a bit to the right
firstPage.drawText(key, { x: c.x + 8, y: c.y - 1, size: 7, color: rgb(0.6, 0, 0), font: helveticaFont }) firstPage.drawText(key, { x: c.x + 8, y: c.y - 1, size: 7, color: rgb(0.6, 0, 0), font: helveticaFont })
} }
} catch (_e) { } catch (debugOverlayError) {
console.warn('Debug overlay fehlgeschlagen:', e.message) console.warn('Debug overlay fehlgeschlagen:', debugOverlayError.message)
} }
} }
} catch (_e) { } catch (positionalDrawingError) {
console.warn('Fehler beim positional drawing:', e.message) console.warn('Fehler beim positional drawing:', positionalDrawingError.message)
} }
const pdfBytes = await pdfDoc.save() const pdfBytes = await pdfDoc.save()
@@ -642,8 +574,8 @@ export default defineEventHandler(async (event) => {
} }
} }
} }
} catch (_e) { } catch (fieldFillError) {
console.warn('Fehler beim Befüllen Feld', fname, e.message) console.warn('Fehler beim Befüllen Feld', fname, fieldFillError.message)
} }
} }
@@ -651,8 +583,8 @@ export default defineEventHandler(async (event) => {
try { try {
const helv2 = await pdfDoc.embedFont(StandardFonts.Helvetica) const helv2 = await pdfDoc.embedFont(StandardFonts.Helvetica)
form.updateFieldAppearances(helv2) form.updateFieldAppearances(helv2)
} catch (_e) { } catch (appearanceError) {
console.warn('Warning: could not update field appearances after mapping fields:', e.message) console.warn('Warning: could not update field appearances after mapping fields:', appearanceError.message)
} }
const pdfBytes = await pdfDoc.save() const pdfBytes = await pdfDoc.save()
@@ -660,14 +592,12 @@ export default defineEventHandler(async (event) => {
} }
let usedTemplate = false let usedTemplate = false
const uploadsDir = getDataPath('uploads')
await fs.mkdir(uploadsDir, { recursive: true })
try { try {
const filled = await fillPdfTemplate(data) const filled = await fillPdfTemplate(data)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented // filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`) finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.writeFile(finalPdfPath, filled) await fs.writeFile(finalPdfPath, filled)
// Do NOT copy filled PDFs into public repo uploads to avoid accidental exposure. // Do NOT copy filled PDFs into public repo uploads to avoid accidental exposure.
usedTemplate = true usedTemplate = true
@@ -678,11 +608,11 @@ export default defineEventHandler(async (event) => {
let emailResult let emailResult
if (usedTemplate) { if (usedTemplate) {
// E-Mail senden // E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmail(data, filename, event) emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
// Antragsdaten verschlüsselt speichern // Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey) const encryptedData = JSON.stringify(data)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented // filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -720,7 +650,14 @@ export default defineEventHandler(async (event) => {
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
// filename is generated from timestamp, tempDir is controlled, command injection prevented // filename is generated from timestamp, tempDir is controlled, command injection prevented
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"` const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
await execAsync(command) try {
await execAsync(command)
} catch (pdflatexError) {
const maybePdfPath = path.join(tempDir, `${filename}.pdf`)
const pdfExists = await fs.stat(maybePdfPath).then(() => true).catch(() => false)
if (!pdfExists) throw pdflatexError
console.warn('pdflatex meldete Fehlercode, aber PDF wurde erzeugt. Fahre fort.')
}
// PDF-Datei in Uploads-Verzeichnis kopieren // PDF-Datei in Uploads-Verzeichnis kopieren
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -728,15 +665,15 @@ export default defineEventHandler(async (event) => {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const pdfPath = path.join(tempDir, `${filename}.pdf`) const pdfPath = path.join(tempDir, `${filename}.pdf`)
await fs.mkdir(uploadsDir, { recursive: true }) await fs.mkdir(uploadsDir, { recursive: true })
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`) finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.copyFile(pdfPath, finalPdfPath) await fs.copyFile(pdfPath, finalPdfPath)
// E-Mail senden // E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmail(data, filename, event) emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
// Antragsdaten verschlüsselt speichern // Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey) const encryptedData = JSON.stringify(data)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented // filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -763,8 +700,8 @@ export default defineEventHandler(async (event) => {
// Fallback: Einfache Textdatei generieren // Fallback: Einfache Textdatei generieren
const fallbackFilename = await generateSimplePDF(data, filename, event) const fallbackFilename = await generateSimplePDF(data, filename, event)
// E-Mail senden (Fallback) // E-Mail senden (Fallback)
const emailResult = await sendMembershipEmail(data, filename, event) const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`))
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung') console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || []) console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
@@ -773,7 +710,6 @@ export default defineEventHandler(async (event) => {
console.log('Upload-Verzeichnis:', getDataPath('uploads')) console.log('Upload-Verzeichnis:', getDataPath('uploads'))
// Verfügbare Dateien auflisten // Verfügbare Dateien auflisten
const uploadsDir = getDataPath('uploads')
try { try {
const files = await fs.readdir(uploadsDir) const files = await fs.readdir(uploadsDir)
console.log('Verfügbare Dateien:', files) console.log('Verfügbare Dateien:', files)

View File

@@ -1,51 +1,37 @@
import { verifyToken, getUserById, migrateUserRoles } from '../utils/auth.js' import { verifyToken, getUserFromToken } from '../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token')
if (!token) { if (!token) {
throw createError({ throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
statusCode: 401,
message: 'Nicht authentifiziert.'
})
} }
const decoded = verifyToken(token) const decoded = verifyToken(token)
if (!decoded) { if (!decoded) {
throw createError({ throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
statusCode: 401,
message: 'Ungültiges Token.'
})
} }
const user = await getUserById(decoded.id) const user = await getUserFromToken(token)
if (!user) {
if (!user || user.active === false) { throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
throw createError({
statusCode: 403,
message: 'Benutzer nicht gefunden oder inaktiv.'
})
} }
const migratedUser = migrateUserRoles({ ...user }) // Rückgabe des eigenen Profils inkl. Sichtbarkeitspräferenzen
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Return user data (without password)
return { return {
success: true, success: true,
user: { user: {
id: user.id, id: user.id,
email: user.email,
name: user.name, name: user.name,
email: user.email,
phone: user.phone || '', phone: user.phone || '',
roles: roles, geburtsdatum: user.geburtsdatum || '',
role: roles[0] || 'mitglied' // Rückwärtskompatibilität visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
} }
} }
} catch (error) { } catch (error) {
console.error('Profil-Abruf-Fehler:', error) console.error('Fehler beim Laden des Profil:', error)
throw error throw error
} }
}) })

View File

@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
} }
const body = await readBody(event) const body = await readBody(event)
const { name, email, phone, currentPassword, newPassword } = body const { name, email, phone, geburtsdatum, currentPassword, newPassword } = body
if (!name || !email) { if (!name || !email) {
throw createError({ throw createError({
@@ -31,7 +31,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
const users = await readUsers() const users = await readUsers()
const userIndex = users.findIndex(u => u.id === decoded.id) const userIndex = users.findIndex(u => u.id === decoded.id)
if (userIndex === -1) { if (userIndex === -1) {
@@ -58,6 +58,19 @@ export default defineEventHandler(async (event) => {
user.name = name user.name = name
user.email = email user.email = email
user.phone = phone || '' user.phone = phone || ''
user.geburtsdatum = geburtsdatum || ''
// Optional visibility preferences (what to show to other logged-in members)
// Expected shape: { showEmail: boolean, showPhone: boolean, showAddress: boolean, showBirthday: boolean }
const visibility = body.visibility || body.visibilityPreferences || null
if (visibility && typeof visibility === 'object') {
user.visibility = user.visibility || {}
// Coerce values to booleans to be robust against string values from clients
if (visibility.showEmail !== undefined) user.visibility.showEmail = Boolean(visibility.showEmail)
if (visibility.showPhone !== undefined) user.visibility.showPhone = Boolean(visibility.showPhone)
if (visibility.showAddress !== undefined) user.visibility.showAddress = Boolean(visibility.showAddress)
if (visibility.showBirthday !== undefined) user.visibility.showBirthday = Boolean(visibility.showBirthday)
}
// Handle password change // Handle password change
if (currentPassword && newPassword) { if (currentPassword && newPassword) {
@@ -93,6 +106,8 @@ export default defineEventHandler(async (event) => {
email: user.email, email: user.email,
name: user.name, name: user.name,
phone: user.phone, phone: user.phone,
geburtsdatum: user.geburtsdatum || '',
visibility: user.visibility || {},
roles: roles, roles: roles,
role: roles[0] || 'mitglied' // Rückwärtskompatibilität role: roles[0] || 'mitglied' // Rückwärtskompatibilität
} }

View File

@@ -13,10 +13,15 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Lade Spielplandaten // Lade Spielplandaten - bevorzugt aus server/data
const csvPath = path.join(process.cwd(), 'public/data/spielplan.csv') let csvPath = path.join(process.cwd(), 'server/data/spielplan.csv')
try {
await fs.access(csvPath)
} catch {
csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
}
let csvContent let csvContent
try { try {
csvContent = await fs.readFile(csvPath, 'utf-8') csvContent = await fs.readFile(csvPath, 'utf-8')
} catch (_error) { } catch (_error) {

View File

@@ -4,13 +4,20 @@ import path from 'path'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const cwd = process.cwd() const cwd = process.cwd()
// In production (.output/server), working dir is .output // Prefer internal server/data, fallback to public/data
let csvPath let csvPath
if (cwd.endsWith('.output')) { if (cwd.endsWith('.output')) {
csvPath = path.join(cwd, '../public/data/termine.csv') csvPath = path.join(cwd, '../server/data/termine.csv')
// fallback
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, '../public/data/termine.csv')
}
} else { } else {
csvPath = path.join(cwd, 'public/data/termine.csv') csvPath = path.join(cwd, 'server/data/termine.csv')
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, 'public/data/termine.csv')
}
} }
const csv = await fs.readFile(csvPath, 'utf-8') const csv = await fs.readFile(csvPath, 'utf-8')

View File

@@ -4,13 +4,19 @@ import path from 'path'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const cwd = process.cwd() const cwd = process.cwd()
// In production (.output/server), working dir is .output // Prefer internal server/data, fallback to public/data
let csvPath let csvPath
if (cwd.endsWith('.output')) { if (cwd.endsWith('.output')) {
csvPath = path.join(cwd, '../public/data/vereinsmeisterschaften.csv') csvPath = path.join(cwd, '../server/data/vereinsmeisterschaften.csv')
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, '../public/data/vereinsmeisterschaften.csv')
}
} else { } else {
csvPath = path.join(cwd, 'public/data/vereinsmeisterschaften.csv') csvPath = path.join(cwd, 'server/data/vereinsmeisterschaften.csv')
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, 'public/data/vereinsmeisterschaften.csv')
}
} }
// CSV-Datei direkt als Text zurückgeben (keine Caching-Probleme) // CSV-Datei direkt als Text zurückgeben (keine Caching-Probleme)

View File

@@ -0,0 +1,44 @@
// Script: set-all-birthday-visible.cjs
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
const fs = require('fs')
const path = require('path')
const membersPath = path.join(__dirname, 'data', 'members.json')
let raw
try {
raw = fs.readFileSync(membersPath, 'utf8')
} catch (e) {
console.error('Fehler beim Lesen von members.json:', e)
process.exit(1)
}
let members
try {
members = JSON.parse(raw)
} catch (e) {
console.error('Fehler beim Parsen von members.json:', e)
process.exit(1)
}
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0
for (const m of members) {
if (!m.visibility) m.visibility = {}
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true
changed++
}
}
if (changed > 0) {
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
}

View File

@@ -0,0 +1,44 @@
// Script: set-all-birthday-visible.js
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
const fs = require('fs')
const path = require('path')
const membersPath = path.join(__dirname, 'data', 'members.json')
let raw
try {
raw = fs.readFileSync(membersPath, 'utf8')
} catch (e) {
console.error('Fehler beim Lesen von members.json:', e)
process.exit(1)
}
let members
try {
members = JSON.parse(raw)
} catch (e) {
console.error('Fehler beim Parsen von members.json:', e)
process.exit(1)
}
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0
for (const m of members) {
if (!m.visibility) m.visibility = {}
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true
changed++
}
}
if (changed > 0) {
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
}

View File

@@ -0,0 +1,33 @@
// Script: set-all-birthday-visible.mjs
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung)
import { readMembers, writeMembers } from './utils/members.js';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
async function main() {
let members = await readMembers();
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0;
for (const m of members) {
if (!m.visibility) m.visibility = {};
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true;
changed++;
}
}
if (changed > 0) {
await writeMembers(members);
console.log(`Flag für ${changed} Mitglieder gesetzt.`);
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.');
}
}
main();

View File

@@ -0,0 +1,72 @@
// Script: set-all-visibility-flags.mjs
// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung)
import { readMembers, writeMembers } from './utils/members.js';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const usersPath = path.resolve(process.cwd(), 'server/data/users.json');
async function updateVisibility(obj) {
let changed = 0;
if (Array.isArray(obj)) {
for (const m of obj) {
if (!m.visibility) m.visibility = {};
if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; }
if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; }
if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; }
if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; }
}
}
return changed;
}
async function updateUsersFile() {
let changed = 0;
try {
let raw = await fs.readFile(usersPath, 'utf8');
let users;
if (raw.trim().startsWith('v2:')) {
// encrypted, try to use decryptObject from encryption.js
const { decryptObject } = await import('./utils/encryption.js');
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
users = decryptObject(raw, key);
} else {
users = JSON.parse(raw);
}
changed = await updateVisibility(users);
// write back (encrypted if vorher encrypted)
if (raw.trim().startsWith('v2:')) {
const { encryptObject } = await import('./utils/encryption.js');
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
const encrypted = encryptObject(users, key);
await fs.writeFile(usersPath, encrypted, 'utf8');
} else {
await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8');
}
return changed;
} catch (e) {
console.error('Fehler beim Bearbeiten von users.json:', e);
return 0;
}
}
async function main() {
let changedMembers = 0;
let changedUsers = 0;
// members.json (manuelle Mitglieder)
let members = await readMembers();
changedMembers = await updateVisibility(members);
if (changedMembers > 0) {
await writeMembers(members);
}
// users.json (Login-System)
changedUsers = await updateUsersFile();
console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`);
}
main();

View File

@@ -0,0 +1,98 @@
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant, never user input
const getDataPath = (filename) => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../server/data', filename)
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'server/data', filename)
}
const CONTACT_REQUESTS_FILE = getDataPath('contact-requests.json')
export async function readContactRequests() {
try {
const raw = await fs.readFile(CONTACT_REQUESTS_FILE, 'utf-8')
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch (error) {
if (error.code === 'ENOENT') return []
console.error('Fehler beim Lesen der Kontaktanfragen:', error)
return []
}
}
export async function writeContactRequests(items) {
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8')
}
export async function createContactRequest(data) {
const current = await readContactRequests()
const now = new Date().toISOString()
const item = {
id: randomUUID(),
createdAt: now,
updatedAt: now,
status: 'offen',
name: data.name,
email: data.email,
phone: data.phone || '',
subject: data.subject,
message: data.message,
replies: []
}
current.unshift(item)
await writeContactRequests(current)
return item
}
export async function addContactReply({ requestId, replyText, responderEmail }) {
const current = await readContactRequests()
const index = current.findIndex((r) => r.id === requestId)
if (index === -1) return null
const now = new Date().toISOString()
const request = current[index]
const replies = Array.isArray(request.replies) ? request.replies : []
replies.push({
id: randomUUID(),
createdAt: now,
responderEmail: responderEmail || '',
message: replyText
})
current[index] = {
...request,
status: 'beantwortet',
replies,
updatedAt: now
}
await writeContactRequests(current)
return current[index]
}
export async function updateContactRequestStatus(requestId, newStatus) {
const validStatuses = ['offen', 'beantwortet']
if (!validStatuses.includes(newStatus)) return null
const current = await readContactRequests()
const index = current.findIndex((r) => r.id === requestId)
if (index === -1) return null
const now = new Date().toISOString()
current[index] = {
...current[index],
status: newStatus,
updatedAt: now
}
await writeContactRequests(current)
return current[index]
}

View File

@@ -6,6 +6,7 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getServerDataPath } from './paths.js'
/** /**
* Gets the correct data path for config files * Gets the correct data path for config files
@@ -15,15 +16,7 @@ import path from 'path'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'config.json'), never user input // filename is always a hardcoded constant (e.g., 'config.json'), never user input
function getDataPath(filename) { function getDataPath(filename) {
const isProduction = process.env.NODE_ENV === 'production' return getServerDataPath(filename)
if (isProduction) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(process.cwd(), '..', 'server', 'data', filename)
} else {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(process.cwd(), 'server', 'data', filename)
}
} }
/** /**
@@ -48,32 +41,36 @@ async function loadConfig() {
* @returns {Array<string>} Email addresses * @returns {Array<string>} Email addresses
*/ */
function getEmailRecipients(data, config) { function getEmailRecipients(data, config) {
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
if (!isProduction) { if (!isProduction) {
return ['tsschulz@tsschulz.de'] return ['tsschulz@tsschulz.de']
} }
const recipients = [] const recipients = []
// Add 1. Vorsitzender // Config uses a 'vorstand' object with nested roles; collect all emails
if (config.vorsitzender && config.vorsitzender.email) { if (config.vorstand && typeof config.vorstand === 'object') {
recipients.push(config.vorsitzender.email) Object.values(config.vorstand).forEach((member) => {
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
recipients.push(member.email.trim())
}
})
} }
// Add Schriftführer // For minors, also add first trainer email if configured (trainer is an array)
if (config.schriftfuehrer && config.schriftfuehrer.email) { if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) {
recipients.push(config.schriftfuehrer.email) recipients.push(config.trainer[0].email)
}
// For minors, also add 1. Trainer
if (!data.isVolljaehrig && config.trainer && config.trainer.email) {
recipients.push(config.trainer.email)
} }
// Fallback if no recipients found // Fallback if no recipients found
if (recipients.length === 0) { if (recipients.length === 0) {
recipients.push('tsschulz@tsschulz.de') // Prefer website verantwortlicher if set
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
recipients.push(config.website.verantwortlicher.email)
} else {
recipients.push('tsschulz@tsschulz.de')
}
} }
return recipients return recipients
@@ -94,7 +91,7 @@ function createTransporter() {
) )
} }
return nodemailer.createTransporter({ return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost', host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT) || 587, port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true', secure: process.env.SMTP_SECURE === 'true',
@@ -161,4 +158,61 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
error: error.message error: error.message
} }
} }
} }
/**
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
* @param {Object} data - { name, email, phone }
*/
export async function sendRegistrationNotification(data) {
try {
const config = await loadConfig()
const recipients = getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
// Notify Vorstand/admin
const adminSubject = 'Neue Registrierung - Harheimer TC'
const adminHtml = `
<h2>Neue Registrierung</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${data.name}</li>
<li><strong>E-Mail:</strong> ${data.email}</li>
<li><strong>Telefon:</strong> ${data.phone || 'Nicht angegeben'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: recipients.join(', '),
subject: adminSubject,
html: adminHtml
})
// Confirmation to user
const userSubject = 'Registrierung erhalten - Harheimer TC'
const userHtml = `
<h2>Registrierung erhalten</h2>
<p>Hallo ${data.name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: data.email,
subject: userSubject,
html: userHtml
})
return { success: true, recipients }
} catch (error) {
console.error('sendRegistrationNotification failed:', error.message || error)
throw error
}
}

View File

@@ -110,7 +110,7 @@ function decryptV2GCM(encryptedData, password) {
} }
const key = deriveKey(password, salt) const key = deriveKey(password, salt)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH })
decipher.setAuthTag(tag) decipher.setAuthTag(tag)
const decrypted = Buffer.concat([ const decrypted = Buffer.concat([

View File

@@ -242,24 +242,35 @@ export async function saveMember(memberData) {
const members = await readMembers() const members = await readMembers()
if (memberData.id) { if (memberData.id) {
// Update existing // Update existing manual member if present.
// If the ID belongs to a login-only member shown in the merged list,
// update an existing manual duplicate or create a manual overlay.
const index = members.findIndex(m => m.id === memberData.id) const index = members.findIndex(m => m.id === memberData.id)
const duplicate = findDuplicateMember(
members.filter(m => m.id !== memberData.id),
memberData.firstName,
memberData.lastName,
memberData.geburtsdatum
)
if (duplicate && index !== -1) {
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
}
if (index !== -1) { if (index !== -1) {
// Check for duplicate (excluding current member)
const duplicate = findDuplicateMember(
members.filter(m => m.id !== memberData.id),
memberData.firstName,
memberData.lastName,
memberData.geburtsdatum
)
if (duplicate) {
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
}
members[index] = { ...members[index], ...memberData } members[index] = { ...members[index], ...memberData }
} else if (duplicate) {
const duplicateIndex = members.findIndex(m => m.id === duplicate.id)
members[duplicateIndex] = {
...members[duplicateIndex],
...memberData,
id: members[duplicateIndex].id
}
} else { } else {
throw new Error('Mitglied nicht gefunden') members.push({
...memberData,
active: typeof memberData.active === 'boolean' ? memberData.active : true
})
} }
} else { } else {
// Add new - check for duplicate first // Add new - check for duplicate first

View File

@@ -236,6 +236,22 @@ export async function getRecipientsByGroup(targetGroup) {
email: m.email, email: m.email,
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || ''
})) }))
// Zusätzlich aktive Trainer aus users.json anschreiben
users
.filter(u => {
if (!u.active || !u.email || !u.email.trim()) return false
const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : [])
return roles.includes('trainer')
})
.forEach(u => {
if (!recipients.find(r => r.email.toLowerCase().trim() === u.email.toLowerCase().trim())) {
recipients.push({
email: u.email.trim(),
name: u.name || ''
})
}
})
break break
case 'mannschaftsspieler': case 'mannschaftsspieler':

32
server/utils/paths.js Normal file
View File

@@ -0,0 +1,32 @@
import fs from 'fs'
import path from 'path'
function uniqueCandidates(candidates) {
return [...new Set(candidates.filter(Boolean))]
}
function hasServerDataDir(root) {
const normalizedRoot = String(root || '').replace(/\/+$/, '')
return fs.existsSync(`${normalizedRoot}/server/data`)
}
export function resolveProjectRoot() {
const envRoot = process.env.APP_ROOT ? process.env.APP_ROOT.trim() : ''
const cwd = process.cwd()
const parent = path.resolve(cwd, '..')
const candidates = uniqueCandidates([envRoot, cwd, parent])
for (const root of candidates) {
if (hasServerDataDir(root)) return root
}
return cwd
}
export function getProjectPath(...segments) {
return path.join(resolveProjectRoot(), ...segments)
}
export function getServerDataPath(...segments) {
return getProjectPath('server', 'data', ...segments)
}

View File

@@ -2,20 +2,16 @@ import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
// Handle both dev and production paths // Use internal server/data directory for Termine CSV to avoid writing to public/
// filename is always a hardcoded constant (e.g., 'termine.csv'), never user input
const getDataPath = (filename) => { const getDataPath = (filename) => {
const cwd = process.cwd() const cwd = process.cwd()
// In production (.output/server), working dir is .output // Prefer server/data in both production and development
// e.g. project-root/server/data/termine.csv or .output/server/data/termine.csv
if (cwd.endsWith('.output')) { if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return `${cwd}/../server/data/${filename}`
return path.join(cwd, '../public/data', filename)
} }
return `${cwd}/server/data/${filename}`
// In development, working dir is project root
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'public/data', filename)
} }
const TERMINE_FILE = getDataPath('termine.csv') const TERMINE_FILE = getDataPath('termine.csv')

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup' import { createEvent, mockSuccessReadBody } from './setup'
import { readFileSync } from 'fs'
vi.mock('../server/utils/auth.js', () => { vi.mock('../server/utils/auth.js', () => {
return { return {
@@ -60,8 +61,14 @@ import logoutHandler from '../server/api/auth/logout.post.js'
import registerHandler from '../server/api/auth/register.post.js' import registerHandler from '../server/api/auth/register.post.js'
import resetPasswordHandler from '../server/api/auth/reset-password.post.js' import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
import statusHandler from '../server/api/auth/status.get.js' import statusHandler from '../server/api/auth/status.get.js'
import versionHandler from '../server/api/app/version.get.js'
describe('Auth API Endpoints', () => { describe('Auth API Endpoints', () => {
afterEach(() => {
delete process.env.NODE_ENV
delete process.env.APP_ENV
})
beforeEach(() => { beforeEach(() => {
// Setze SMTP-Credentials für Tests // Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com' process.env.SMTP_USER = 'test@example.com'
@@ -134,15 +141,29 @@ describe('Auth API Endpoints', () => {
it('verhindert doppelte Benutzer', async () => { it('verhindert doppelte Benutzer', async () => {
const event = createEvent() const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678' }) mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', geburtsdatum: '2000-01-01' })
authUtils.readUsers.mockResolvedValue([{ email: 'max@example.com' }]) authUtils.readUsers.mockResolvedValue([{ email: 'max@example.com' }])
await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 409 }) await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 409 })
}) })
it('verlangt Geburtsdatum bei Registrierung', async () => {
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678' })
await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 400 })
})
it('legt Benutzer an und versendet E-Mails', async () => { it('legt Benutzer an und versendet E-Mails', async () => {
const event = createEvent() const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', phone: '123' }) mockSuccessReadBody({
name: 'Max',
email: 'max@example.com',
password: '12345678',
phone: '123',
geburtsdatum: '2000-01-01',
visibility: { showBirthday: false }
})
authUtils.readUsers.mockResolvedValue([]) authUtils.readUsers.mockResolvedValue([])
authUtils.hashPassword.mockResolvedValue('hashed') authUtils.hashPassword.mockResolvedValue('hashed')
authUtils.writeUsers.mockResolvedValue(true) authUtils.writeUsers.mockResolvedValue(true)
@@ -151,8 +172,36 @@ describe('Auth API Endpoints', () => {
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled() expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.writeUsers.mock.calls[0][0][0]).toMatchObject({
geburtsdatum: '2000-01-01',
visibility: { showBirthday: false }
})
expect(nodemailer.default.createTransport).toHaveBeenCalled() expect(nodemailer.default.createTransport).toHaveBeenCalled()
}) })
it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
const event = createEvent()
mockSuccessReadBody({
name: 'Max',
email: 'max@example.com',
password: '12345678',
phone: '123',
geburtsdatum: '2000-01-01'
})
authUtils.readUsers.mockResolvedValue([])
authUtils.hashPassword.mockResolvedValue('hashed')
authUtils.writeUsers.mockResolvedValue(true)
await registerHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail).toHaveBeenNthCalledWith(1, expect.objectContaining({
to: 'tsschulz@tsschulz.de'
}))
})
}) })
describe('POST /api/auth/reset-password', () => { describe('POST /api/auth/reset-password', () => {
@@ -194,4 +243,22 @@ describe('Auth API Endpoints', () => {
expect(response.user).toMatchObject({ id: '1' }) expect(response.user).toMatchObject({ id: '1' })
}) })
}) })
describe('GET /api/app/version', () => {
it('verlangt Login', async () => {
const event = createEvent()
await expect(versionHandler(event)).rejects.toMatchObject({ statusCode: 401 })
})
it('liefert eingeloggten Benutzern die package.json-Version', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.getUserFromToken.mockResolvedValue({ id: '1', email: 'user@example.com', roles: ['mitglied'] })
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const response = await versionHandler(event)
expect(response.version).toBe(packageJson.version)
})
})
}) })

View File

@@ -37,7 +37,38 @@ vi.mock('child_process', () => ({
})) }))
vi.mock('util', () => ({ vi.mock('util', () => ({
promisify: () => () => Promise.resolve({ stdout: 'PDF Inhalt', stderr: '' }) promisify: () => () => Promise.resolve({
stdout: `§ 1 Name und Sitz
Der Verein führt den Namen Harheimer TC.
§ 2 Zweck
Der Verein verfolgt ausschließlich und unmittelbar gemeinnützige Zwecke.
§ 3 Mitgliedschaft
(1) Mitglied kann jede natürliche Person werden.
(2) Über die Aufnahme entscheidet der Vorstand.
§ 4 Beiträge
Die Mitglieder zahlen Beiträge nach Maßgabe der Beitragsordnung.
§ 5 Vorstand
Der Vorstand besteht aus dem Vorsitzenden, dem Schriftführer und dem Kassenwart.
§ 6 Schlussbestimmungen
Diese Satzung tritt mit Beschluss der Mitgliederversammlung in Kraft.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
`,
stderr: ''
})
}))
vi.mock('../server/utils/upload-validation.js', () => ({
assertPdfMagicHeader: vi.fn().mockResolvedValue(undefined)
})) }))
import saveCsvHandler from '../server/api/cms/save-csv.post.js' import saveCsvHandler from '../server/api/cms/save-csv.post.js'
@@ -67,11 +98,26 @@ describe('CMS File Endpoints', () => {
mockSuccessReadBody({ filename: 'mannschaften.csv', content: 'data' }) mockSuccessReadBody({ filename: 'mannschaften.csv', content: 'data' })
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined) vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined) vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
vi.spyOn(fs, 'rename').mockResolvedValue(undefined)
vi.spyOn(fs, 'stat').mockResolvedValue({ size: Buffer.byteLength('data', 'utf8') } as any)
const response = await saveCsvHandler(event) const response = await saveCsvHandler(event)
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(fs.writeFile).toHaveBeenCalled() expect(fs.writeFile).toHaveBeenCalled()
}) })
it('erlaubt vorstand beim CSV-Speichern', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({ filename: 'spielplan.csv', content: 'kopf;wert' })
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
vi.spyOn(fs, 'rename').mockResolvedValue(undefined)
vi.spyOn(fs, 'stat').mockResolvedValue({ size: Buffer.byteLength('kopf;wert', 'utf8') } as any)
getUserFromToken.mockResolvedValue({ id: 'vorstand', role: 'vorstand' })
const response = await saveCsvHandler(event)
expect(response.success).toBe(true)
})
}) })
describe('POST /api/cms/upload-spielplan-pdf', () => { describe('POST /api/cms/upload-spielplan-pdf', () => {

View File

@@ -5,6 +5,11 @@ vi.mock('../server/utils/auth.js', () => ({
getUserFromToken: vi.fn(), getUserFromToken: vi.fn(),
readUsers: vi.fn(), readUsers: vi.fn(),
writeUsers: vi.fn(), writeUsers: vi.fn(),
hasRole: vi.fn((user, role) => {
if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
return userRoles.includes(role)
}),
hasAnyRole: vi.fn((user, ...roles) => { hasAnyRole: vi.fn((user, ...roles) => {
if (!user) return false if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])

View File

@@ -17,6 +17,7 @@ vi.mock('../server/utils/auth.js', () => ({
vi.mock('sharp', () => ({ vi.mock('sharp', () => ({
default: vi.fn(() => ({ default: vi.fn(() => ({
metadata: vi.fn().mockResolvedValue({ width: 1200, height: 800 }),
resize: vi.fn().mockReturnThis(), resize: vi.fn().mockReturnThis(),
rotate: vi.fn().mockReturnThis(), rotate: vi.fn().mockReturnThis(),
toFile: vi.fn().mockResolvedValue({}), toFile: vi.fn().mockResolvedValue({}),

View File

@@ -1,9 +1,11 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup' import { createEvent, mockSuccessReadBody } from './setup'
vi.mock('../server/utils/auth.js', () => ({ vi.mock('../server/utils/auth.js', () => ({
verifyToken: vi.fn(), verifyToken: vi.fn(),
getUserById: vi.fn(), getUserById: vi.fn(),
getUserFromToken: vi.fn(),
readUsers: vi.fn(), readUsers: vi.fn(),
readMembers: vi.fn(), readMembers: vi.fn(),
writeUsers: vi.fn(), writeUsers: vi.fn(),
@@ -24,6 +26,11 @@ vi.mock('../server/utils/auth.js', () => ({
if (!user) return false if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
return roles.some(r => userRoles.includes(r)) return roles.some(r => userRoles.includes(r))
}),
hasRole: vi.fn((user, role) => {
if (!user) return false
const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : [])
return userRoles.includes(role)
}) })
})) }))
@@ -58,16 +65,35 @@ describe('Members API Endpoints', () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.verifyToken.mockReturnValue({ id: '1' })
memberUtils.readMembers.mockResolvedValue([ memberUtils.readMembers.mockResolvedValue([
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de' } { id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', hasHallKey: true }
]) ])
authUtils.readUsers.mockResolvedValue([ authUtils.readUsers.mockResolvedValue([
{ id: 'u1', name: 'Ben Nutzer', email: 'ben@club.de', role: 'mitglied', active: true } { id: 'u1', name: 'Ben Nutzer', email: 'ben@club.de', role: 'mitglied', active: true }
]) ])
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied' })
const response = await membersGetHandler(event) const response = await membersGetHandler(event)
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(response.members).toHaveLength(2) expect(response.members).toHaveLength(2)
expect(response.members[0]).toHaveProperty('hasHallKey', true)
})
it('zeigt Legacy-Mitglieder ohne active-Flag weiterhin an', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' })
memberUtils.readMembers.mockResolvedValue([
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01' },
{ id: 'm2', firstName: 'Offen', lastName: 'Antrag', geburtsdatum: '2001-01-01', status: 'pending' }
])
authUtils.readUsers.mockResolvedValue([])
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied' })
const response = await membersGetHandler(event)
expect(response.success).toBe(true)
expect(response.members).toHaveLength(1)
expect(response.members[0].name).toBe('Anna Muster')
}) })
}) })
@@ -76,7 +102,8 @@ describe('Members API Endpoints', () => {
firstName: 'Lisa', firstName: 'Lisa',
lastName: 'Beispiel', lastName: 'Beispiel',
geburtsdatum: '2000-01-01', geburtsdatum: '2000-01-01',
email: 'lisa@example.com' email: 'lisa@example.com',
hasHallKey: true
} }
it('verweigert Zugriff ohne Token', async () => { it('verweigert Zugriff ohne Token', async () => {
@@ -88,8 +115,7 @@ describe('Members API Endpoints', () => {
it('verlangt Admin- oder Vorstand-Rolle', async () => { it('verlangt Admin- oder Vorstand-Rolle', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
authUtils.verifyToken.mockReturnValue({ id: '2' }) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'mitglied' })
authUtils.getUserById.mockResolvedValue({ id: '2', role: 'mitglied' })
await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 403 }) await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 403 })
}) })
@@ -97,8 +123,7 @@ describe('Members API Endpoints', () => {
it('gibt 409 bei Duplikaten zurück', async () => { it('gibt 409 bei Duplikaten zurück', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
authUtils.verifyToken.mockReturnValue({ id: '2' }) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.getUserById.mockResolvedValue({ id: '2', role: 'admin' })
memberUtils.saveMember.mockRejectedValue(new Error('existiert bereits')) memberUtils.saveMember.mockRejectedValue(new Error('existiert bereits'))
await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 409 }) await expect(membersPostHandler(event)).rejects.toMatchObject({ statusCode: 409 })
@@ -107,13 +132,43 @@ describe('Members API Endpoints', () => {
it('speichert Mitglied erfolgreich', async () => { it('speichert Mitglied erfolgreich', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
authUtils.verifyToken.mockReturnValue({ id: '2' }) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.getUserById.mockResolvedValue({ id: '2', role: 'admin' })
memberUtils.saveMember.mockResolvedValue(true) memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event) const response = await membersPostHandler(event)
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(memberUtils.saveMember).toHaveBeenCalled() expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
hasHallKey: true
}))
})
it('erlaubt vorstand beim Speichern', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody)
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
})
it('erlaubt Updates von Altdaten ohne Geburtsdatum', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({
id: 'legacy-1',
firstName: 'Lisa',
lastName: 'Beispiel',
email: 'lisa@example.com'
})
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
id: 'legacy-1',
geburtsdatum: ''
}))
}) })
}) })

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup' import { createEvent, mockSuccessReadBody } from './setup'
import fsPromises from 'fs/promises' import fsPromises from 'fs/promises'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
@@ -26,6 +26,11 @@ import termineHandler from '../server/api/termine.get.js'
import spielplaeneHandler from '../server/api/spielplaene.get.js' import spielplaeneHandler from '../server/api/spielplaene.get.js'
describe('Öffentliche API-Endpunkte', () => { describe('Öffentliche API-Endpunkte', () => {
afterEach(() => {
delete process.env.NODE_ENV
delete process.env.APP_ENV
})
beforeEach(() => { beforeEach(() => {
// Setze SMTP-Credentials für Tests // Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com' process.env.SMTP_USER = 'test@example.com'
@@ -58,6 +63,21 @@ describe('Öffentliche API-Endpunkte', () => {
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(nodemailer.default.createTransport).toHaveBeenCalled() expect(nodemailer.default.createTransport).toHaveBeenCalled()
}) })
it('sendet in Testumgebung nicht an Vorstand-Empfänger', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
await contactHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
to: 'tsschulz@tsschulz.de'
}))
})
}) })
describe('GET /api/galerie', () => { describe('GET /api/galerie', () => {