diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..aa26da7
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,34 @@
+# Dependencies
+node_modules
+npm-debug.log
+yarn-debug.log
+yarn-error.log
+
+# Next.js build output
+.next
+out
+
+# Git
+.git
+.gitignore
+
+# Docker
+Dockerfile
+.dockerignore
+
+# Environment variables
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# IDE files
+.idea
+.vscode
+*.swp
+*.swo
+
+# OS files
+.DS_Store
+Thumbs.db
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8a17447
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,55 @@
+# Stage 1: Build stage
+FROM node:18-alpine AS builder
+
+# Set working directory
+WORKDIR /app
+
+# Copy package.json and package-lock.json
+COPY package*.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy all files
+COPY . .
+
+# Build the application
+RUN npm run build
+
+# Stage 2: Production stage
+FROM node:18-alpine AS runner
+
+# Set working directory
+WORKDIR /app
+
+# Set environment variables
+ENV NODE_ENV=production
+
+# Create app directory
+RUN mkdir -p /app
+
+# Don't run production as root
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+RUN chown -R nextjs:nodejs /app
+
+# Copy necessary files from builder stage
+COPY --from=builder --chown=nextjs:nodejs /app/package*.json ./
+COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./
+COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
+COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
+
+# Create data directory and ensure it's writable
+RUN mkdir -p /app/data
+COPY --from=builder --chown=nextjs:nodejs /app/data ./data
+RUN chown -R nextjs:nodejs /app/data
+
+# Switch to non-root user
+USER nextjs
+
+# Expose the port the app will run on
+EXPOSE 3000
+
+# Command to run the application
+CMD ["npm", "start"]
diff --git a/README.md b/README.md
index e215bc4..7c27df6 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,126 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# SCHAFWASCHENER SEGELVEREIN Voting System
+
+A secure web application for conducting online votes for SCHAFWASCHENER SEGELVEREIN club members.
+
+## Features
+
+- **Secure One-Time Voting Links**: Admin can generate unique, one-time use voting links that are cryptographically signed.
+- **Simple Voting Interface**: Members can easily vote Yes, No, or Abstain on proposed statute amendments.
+- **Optional Comments**: Members can provide anonymous feedback or suggestions.
+- **Results Dashboard**: Admin can view real-time voting statistics.
+- **Dark Mode Support**: Automatically adapts to user's system preferences.
+- **WYSIWYG Text Editor**: Admin can edit text content using a rich text editor.
+- **JWT Authentication**: Admin authentication using JWT tokens stored in cookies.
+- **Secure Key Management**: JWT keys are randomly generated and stored securely.
+
+## Technical Implementation
+
+- Built with Next.js 15 and React 19
+- Styled with Tailwind CSS
+- JWT-like token system for secure, one-time voting links
+- File-based storage for vote data
## Getting Started
-First, run the development server:
+### Prerequisites
-```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
-```
+- Node.js 18+ and npm
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+### Installation
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+1. Clone the repository
+2. Install dependencies:
+ ```
+ npm install
+ ```
+3. Run the development server:
+ ```
+ npm run dev
+ ```
+4. Open [http://localhost:3000](http://localhost:3000) in your browser
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+### Admin Access
-## Learn More
+The default admin password is `schafwaschener-segelverein-admin`. This can be changed by setting the `ADMIN_PASSWORD` environment variable.
-To learn more about Next.js, take a look at the following resources:
+Once logged in, the admin session is maintained using JWT tokens, so you don't need to enter the password for each action.
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## Usage
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+### Admin
-## Deploy on Vercel
+1. Navigate to `/admin` and enter the admin password
+2. Generate voting links and share them with club members
+3. View voting statistics in real-time
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+### Voters
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+1. Click on the unique voting link received
+2. Select your vote (Yes, No, or Abstain)
+3. Optionally add comments or suggestions
+4. Submit your vote
+
+## Environment Variables
+
+- `ADMIN_PASSWORD`: Password for admin access (default: "schafwaschener-segelverein-admin")
+- `NEXT_PUBLIC_BASE_URL`: Base URL for generating voting links (default: auto-detected)
+- `JWT_SECRET_KEY`: Secret key for JWT signing (default: randomly generated)
+
+## Production Deployment
+
+### Standard Deployment
+
+For production deployment, consider the following:
+
+1. Set a secure admin password using the `ADMIN_PASSWORD` environment variable
+2. Set a secure JWT secret key using the `JWT_SECRET_KEY` environment variable
+3. Use a database instead of file-based storage
+4. Set up HTTPS for secure communication
+
+### Docker Deployment
+
+The application can be easily deployed using Docker:
+
+#### Using Docker Compose (Recommended)
+
+1. Make sure you have Docker and Docker Compose installed
+2. Run the application:
+ ```
+ docker-compose up -d
+ ```
+ Or with a custom admin password:
+ ```
+ ADMIN_PASSWORD=your-secure-password docker-compose up -d
+ ```
+3. Access the application at [http://localhost:3000](http://localhost:3000)
+4. To stop the application:
+ ```
+ docker-compose down
+ ```
+
+#### Using Docker Directly
+
+1. Build the Docker image:
+ ```
+ ./build-docker.sh
+ ```
+ or manually:
+ ```
+ docker build -t ssvc-rimsting-vote:latest .
+ ```
+
+2. Run the container:
+ ```
+ docker run -p 3000:3000 ssvc-rimsting-vote:latest
+ ```
+
+3. For persistent data storage and custom admin password:
+ ```
+ docker run -p 3000:3000 -v $(pwd)/data:/app/data -e ADMIN_PASSWORD=your-secure-password ssvc-rimsting-vote:latest
+ ```
+
+## License
+
+This project is proprietary and confidential. Unauthorized copying, distribution, or use is strictly prohibited.
+
+© SCHAFWASCHENER SEGELVEREIN
diff --git a/build-docker.sh b/build-docker.sh
new file mode 100755
index 0000000..6daf2f1
--- /dev/null
+++ b/build-docker.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+# Set image name and tag
+IMAGE_NAME="ssvc-rimsting-vote"
+TAG="latest"
+
+# Build the Docker image
+echo "Building Docker image: ${IMAGE_NAME}:${TAG}"
+docker build -t ${IMAGE_NAME}:${TAG} .
+
+# Check if build was successful
+if [ $? -eq 0 ]; then
+ echo "Docker image built successfully!"
+ echo ""
+ echo "Image details:"
+ docker images ${IMAGE_NAME}:${TAG}
+ echo ""
+ echo "To run the container:"
+ echo "docker run -p 3000:3000 ${IMAGE_NAME}:${TAG}"
+ echo ""
+ echo "To run the container with persistent data volume:"
+ echo "docker run -p 3000:3000 -v $(pwd)/data:/app/data ${IMAGE_NAME}:${TAG}"
+else
+ echo "Error building Docker image"
+ exit 1
+fi
diff --git a/data/editable_text.json b/data/editable_text.json
new file mode 100644
index 0000000..4c10b2d
--- /dev/null
+++ b/data/editable_text.json
@@ -0,0 +1,35 @@
+[
+ {
+ "id": "welcome-text",
+ "content": "
Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.
Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.
"
+ },
+ {
+ "id": "current-vote-text",
+ "content": "Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.
Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.
Bei Fragen wenden Sie sich bitte an den Vorstand.
"
+ },
+ {
+ "id": "vote-question",
+ "content": "Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?
"
+ },
+ {
+ "id": "vote-options",
+ "content": [
+ {
+ "id": "yes",
+ "label": "Ja, ich stimme zu"
+ },
+ {
+ "id": "no",
+ "label": "Nein, ich stimme nicht zu"
+ },
+ {
+ "id": "abstain",
+ "label": "Ich enthalte mich"
+ },
+ {
+ "id": "nichts",
+ "label": "Wild"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/data/jwt_keys.json b/data/jwt_keys.json
new file mode 100644
index 0000000..bcd9d54
--- /dev/null
+++ b/data/jwt_keys.json
@@ -0,0 +1,4 @@
+{
+ "privateKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264",
+ "publicKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264"
+}
\ No newline at end of file
diff --git a/data/member_credentials.json b/data/member_credentials.json
new file mode 100644
index 0000000..90c5ec5
--- /dev/null
+++ b/data/member_credentials.json
@@ -0,0 +1,13 @@
+[
+ {
+ "memberNumber": "test",
+ "password": "$2b$10$.Q68xrauRpHLKVCKQP6veeFE3NbgX.uv9gIrHDvbgztWU8K/d2pgG",
+ "hasVoted": false
+ },
+ {
+ "memberNumber": "123",
+ "password": "$2b$10$Lu9T/9qWftBU//G2n.URf.xvQupJpIm7nOG2R0kbOS5TOs0TELrcy",
+ "hasVoted": false,
+ "lastLogin": "2025-03-02T15:59:48.419Z"
+ }
+]
\ No newline at end of file
diff --git a/data/responses.json b/data/responses.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/responses.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/data/settings.json b/data/settings.json
new file mode 100644
index 0000000..c6418b2
--- /dev/null
+++ b/data/settings.json
@@ -0,0 +1,3 @@
+{
+ "memberAuthEnabled": true
+}
\ No newline at end of file
diff --git a/data/used_tokens.json b/data/used_tokens.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/used_tokens.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9ae9322
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+version: '3'
+
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: ssvc-rimsting-vote:latest
+ container_name: ssvc-rimsting-vote
+ ports:
+ - "3000:3000"
+ volumes:
+ - ./data:/app/data
+ restart: unless-stopped
+ environment:
+ - NODE_ENV=production
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:-schafwaschener-segelverein-admin}
+ - JWT_SECRET_KEY=${JWT_SECRET_KEY:-}
diff --git a/package-lock.json b/package-lock.json
index 5d59720..d42264a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,14 @@
"name": "ssvc-rimsting-vote",
"version": "0.1.0",
"dependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "bcryptjs": "^3.0.2",
+ "jose": "^6.0.8",
"next": "15.2.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "react-quill-new": "^3.3.3",
+ "uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -1060,6 +1065,12 @@
"tailwindcss": "4.0.9"
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -1637,6 +1648,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
+ "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -2645,6 +2665,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2652,6 +2678,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "license": "Apache-2.0"
+ },
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -3550,6 +3582,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.0.8",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.8.tgz",
+ "integrity": "sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3919,6 +3960,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4300,6 +4366,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4451,6 +4523,35 @@
],
"license": "MIT"
},
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
@@ -4479,6 +4580,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-quill-new": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.3.3.tgz",
+ "integrity": "sha512-jxbm1QUJlkuGUpc9/GUgGw5USLHdp43H0M7AufqS3V+zRLng9uqLeVBGjXYqEbUKi8QVOM4SClSV3F7kVNj68w==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21",
+ "quill": "~2.0.2"
+ },
+ "peerDependencies": {
+ "quill-delta": "^5.1.0",
+ "react": "^16 || ^17 || ^18 || ^19",
+ "react-dom": "^16 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5338,6 +5454,19 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index 2589435..ae9c048 100644
--- a/package.json
+++ b/package.json
@@ -9,19 +9,24 @@
"lint": "next lint"
},
"dependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "bcryptjs": "^3.0.2",
+ "jose": "^6.0.8",
+ "next": "15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "next": "15.2.0"
+ "react-quill-new": "^3.3.3",
+ "uuid": "^11.1.0"
},
"devDependencies": {
- "typescript": "^5",
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@tailwindcss/postcss": "^4",
- "tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.2.0",
- "@eslint/eslintrc": "^3"
+ "tailwindcss": "^4",
+ "typescript": "^5"
}
}
diff --git a/public/ssvc-logo.svg b/public/ssvc-logo.svg
new file mode 100644
index 0000000..b663dca
--- /dev/null
+++ b/public/ssvc-logo.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/run-docker.sh b/run-docker.sh
new file mode 100755
index 0000000..9bd3c8d
--- /dev/null
+++ b/run-docker.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# Set image name and tag
+IMAGE_NAME="ssvc-rimsting-vote"
+TAG="latest"
+
+# Check if the image exists
+if [[ "$(docker images -q ${IMAGE_NAME}:${TAG} 2> /dev/null)" == "" ]]; then
+ echo "Image ${IMAGE_NAME}:${TAG} not found. Building it first..."
+ ./build-docker.sh
+
+ # Exit if build failed
+ if [ $? -ne 0 ]; then
+ echo "Failed to build the image. Exiting."
+ exit 1
+ fi
+fi
+
+# Run the container
+echo "Starting container from image ${IMAGE_NAME}:${TAG}..."
+echo "The application will be available at http://localhost:3000"
+
+# Stop any existing container with the same name
+CONTAINER_NAME="ssvc-rimsting-vote"
+if [ "$(docker ps -aq -f name=${CONTAINER_NAME})" ]; then
+ echo "Stopping existing container..."
+ docker stop ${CONTAINER_NAME} > /dev/null
+ docker rm ${CONTAINER_NAME} > /dev/null
+fi
+
+# Run the container with data volume mounted
+docker run -d \
+ --name ${CONTAINER_NAME} \
+ -p 3000:3000 \
+ -v "$(pwd)/data:/app/data" \
+ -e "ADMIN_PASSWORD=${ADMIN_PASSWORD:-schafwaschener-segelverein-admin}" \
+ --restart unless-stopped \
+ ${IMAGE_NAME}:${TAG}
+
+echo "Container started successfully!"
+echo "To view logs: docker logs ${CONTAINER_NAME}"
+echo "To stop: docker stop ${CONTAINER_NAME}"
diff --git a/src/app/abstimmung/page.tsx b/src/app/abstimmung/page.tsx
new file mode 100644
index 0000000..e9b0719
--- /dev/null
+++ b/src/app/abstimmung/page.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { VoteOptionConfig } from '@/lib/survey';
+
+// Define a more flexible stats interface
+interface Stats {
+ total: number;
+ [key: string]: number; // Allow any string keys for dynamic vote options
+}
+
+export default function PublicResultsPage() {
+ const [stats, setStats] = useState(null);
+ const [voteOptions, setVoteOptions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Fetch vote options
+ useEffect(() => {
+ const fetchVoteOptions = async () => {
+ try {
+ const response = await fetch('/api/editable-text');
+ if (response.ok) {
+ const data = await response.json();
+ if (data.texts && Array.isArray(data.texts)) {
+ const voteOptionsEntry = data.texts.find((text: any) => text.id === 'vote-options');
+ if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
+ setVoteOptions(voteOptionsEntry.content);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Error fetching vote options:', error);
+ // Use default options if fetch fails
+ setVoteOptions([
+ { id: 'yes', label: 'Ja' },
+ { id: 'no', label: 'Nein' },
+ { id: 'abstain', label: 'Enthaltung' }
+ ]);
+ }
+ };
+
+ fetchVoteOptions();
+ }, []);
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ try {
+ const response = await fetch('/api/public-stats');
+
+ if (!response.ok) {
+ throw new Error('Fehler beim Abrufen der Statistiken');
+ }
+
+ const data = await response.json();
+ setStats(data.stats);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchStats();
+ }, []);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
{error}
+
+ Zurück zur Startseite
+
+
+
+ );
+ }
+
+ return (
+
+
+
ABSTIMMUNGSERGEBNISSE
+
+
+
Aktuelle Ergebnisse der Satzungsänderung
+
+ {stats && voteOptions.length > 0 && (
+
+
+ {voteOptions.map((option) => (
+
+
{stats[option.id] || 0}
+
{option.label}
+
+ ))}
+
+
+
+
{stats.total}
+
Gesamtstimmen
+
+
+ {stats.total > 0 && (
+
+
Ergebnisübersicht
+
+
+ {voteOptions.map((option, index) => {
+ const percentage = ((stats[option.id] || 0) / stats.total) * 100;
+ // Define colors for different options
+ const colors = [
+ 'bg-[#0057a6]', // Blue for first option (usually yes)
+ 'bg-red-500', // Red for second option (usually no)
+ 'bg-gray-500' // Gray for third option (usually abstain)
+ ];
+ // Use a default color for additional options
+ const color = index < colors.length ? colors[index] : 'bg-green-500';
+
+ return (
+
+ );
+ })}
+
+
+
+ {voteOptions.map((option, index) => {
+ const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100);
+ // Define text colors for different options
+ const textColors = [
+ 'text-[#0057a6]', // Blue for first option (usually yes)
+ 'text-red-600', // Red for second option (usually no)
+ 'text-gray-600' // Gray for third option (usually abstain)
+ ];
+ // Use a default color for additional options
+ const textColor = index < textColors.length ? textColors[index] : 'text-green-600';
+
+ return (
+
+ {percentage}% {option.label}
+
+ );
+ })}
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
new file mode 100644
index 0000000..ca9fb7c
--- /dev/null
+++ b/src/app/admin/page.tsx
@@ -0,0 +1,948 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { VoteOption } from '@/lib/survey';
+import QuillEditor from '@/components/QuillEditor';
+import MembersManager from '@/components/MembersManager';
+
+// Define types based on the data structure
+interface Stats {
+ total: number;
+ [key: string]: number; // Allow dynamic keys for vote options
+}
+
+interface Comment {
+ vote: VoteOption;
+ comment: string;
+ timestamp: string;
+}
+
+interface EditableText {
+ id: string;
+ content: string;
+}
+
+export default function AdminPage() {
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [generatedLink, setGeneratedLink] = useState(null);
+ const [bulkTokenCount, setBulkTokenCount] = useState(10);
+ const [isGeneratingBulk, setIsGeneratingBulk] = useState(false);
+ const [isResetting, setIsResetting] = useState(false);
+ const [showStats, setShowStats] = useState(false);
+ const [stats, setStats] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [showEditor, setShowEditor] = useState(false);
+ const [editableTexts, setEditableTexts] = useState([
+ { id: 'welcome-text', content: 'Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.
Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.
' },
+ { id: 'current-vote-text', content: 'Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.
Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.
Bei Fragen wenden Sie sich bitte an den Vorstand.
' },
+ { id: 'vote-question', content: 'Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?
' }
+ ]);
+ const [voteOptions, setVoteOptions] = useState<{ id: string; label: string }[]>([
+ { id: 'yes', label: 'Ja, ich stimme zu' },
+ { id: 'no', label: 'Nein, ich stimme nicht zu' },
+ { id: 'abstain', label: 'Ich enthalte mich' }
+ ]);
+ const [showVoteOptions, setShowVoteOptions] = useState(false);
+ const [newOptionId, setNewOptionId] = useState('');
+ const [newOptionLabel, setNewOptionLabel] = useState('');
+ const [selectedTextId, setSelectedTextId] = useState(null);
+ const [editorContent, setEditorContent] = useState('');
+ const [showMembers, setShowMembers] = useState(false);
+ const [memberAuthEnabled, setMemberAuthEnabled] = useState(false);
+ const [isTogglingMemberAuth, setIsTogglingMemberAuth] = useState(false);
+
+ // Check if already authenticated and load settings on component mount
+ useEffect(() => {
+ const checkAuthAndSettings = async () => {
+ try {
+ // Check authentication
+ const response = await fetch('/api/generate-token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}), // Empty body to check if cookie auth works
+ });
+
+ if (response.ok) {
+ setIsAuthenticated(true);
+
+ // Get current settings
+ const settingsResponse = await fetch('/api/settings');
+ if (settingsResponse.ok) {
+ const data = await settingsResponse.json();
+ setMemberAuthEnabled(data.settings.memberAuthEnabled);
+ }
+ }
+ } catch (err) {
+ // Silently fail, user will need to enter password
+ }
+ };
+
+ checkAuthAndSettings();
+ }, []);
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/generate-token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Anmeldung fehlgeschlagen');
+ }
+
+ setIsAuthenticated(true);
+ setPassword(''); // Clear password field after successful login
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleGenerateToken = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/generate-token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}), // No need to send password, using JWT cookie
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Token konnte nicht generiert werden');
+ }
+
+ setGeneratedLink(data.voteUrl);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleGetStats = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/stats', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}), // No need to send password, using JWT cookie
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Statistiken konnten nicht abgerufen werden');
+ }
+
+ setStats(data.stats);
+ setComments(data.comments || []);
+ setShowStats(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleEditText = (textId: string) => {
+ const textToEdit = editableTexts.find(text => text.id === textId);
+ if (textToEdit) {
+ setSelectedTextId(textId);
+ setEditorContent(textToEdit.content);
+ setShowEditor(true);
+ }
+ };
+
+ // Load editable texts and vote options on component mount
+ useEffect(() => {
+ const loadEditableTexts = async () => {
+ try {
+ const response = await fetch('/api/editable-text');
+ if (response.ok) {
+ const data = await response.json();
+ if (data.texts && Array.isArray(data.texts)) {
+ setEditableTexts(data.texts);
+
+ // Find and set vote options
+ const voteOptionsEntry = data.texts.find((text: any) => text.id === 'vote-options');
+ if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
+ setVoteOptions(voteOptionsEntry.content);
+ }
+ }
+ }
+ } catch (err) {
+ console.error('Error loading editable texts:', err);
+ }
+ };
+
+ if (isAuthenticated) {
+ loadEditableTexts();
+ }
+ }, [isAuthenticated]);
+
+ const handleSaveText = async () => {
+ if (selectedTextId) {
+ setIsLoading(true);
+
+ try {
+ // Update local state
+ setEditableTexts(prev =>
+ prev.map(text =>
+ text.id === selectedTextId
+ ? { ...text, content: editorContent }
+ : text
+ )
+ );
+
+ // Save to server
+ const response = await fetch('/api/editable-text', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: selectedTextId,
+ content: editorContent
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save text');
+ }
+
+ setShowEditor(false);
+ setSelectedTextId(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ // Add a new vote option
+ const handleAddVoteOption = async () => {
+ if (!newOptionId || !newOptionLabel) {
+ setError('Bitte geben Sie sowohl eine ID als auch ein Label für die neue Option ein');
+ return;
+ }
+
+ // Check if ID already exists
+ if (voteOptions.some(option => option.id === newOptionId)) {
+ setError('Eine Option mit dieser ID existiert bereits');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Add to local state
+ const updatedOptions = [...voteOptions, { id: newOptionId, label: newOptionLabel }];
+ setVoteOptions(updatedOptions);
+
+ // Save to server
+ const response = await fetch('/api/editable-text', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 'vote-options',
+ content: updatedOptions
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
+ }
+
+ // Reset form
+ setNewOptionId('');
+ setNewOptionLabel('');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Remove a vote option
+ const handleRemoveVoteOption = async (optionId: string) => {
+ // Don't allow removing if there are less than 2 options
+ if (voteOptions.length <= 1) {
+ setError('Es muss mindestens eine Abstimmungsoption vorhanden sein');
+ return;
+ }
+
+ // Show confirmation dialog
+ if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionId}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Remove from local state
+ const updatedOptions = voteOptions.filter(option => option.id !== optionId);
+ setVoteOptions(updatedOptions);
+
+ // Save to server
+ const response = await fetch('/api/editable-text', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 'vote-options',
+ content: updatedOptions
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Update a vote option
+ const handleUpdateVoteOption = async (optionId: string, newLabel: string) => {
+ if (!newLabel.trim()) {
+ setError('Das Label darf nicht leer sein');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Update in local state
+ const updatedOptions = voteOptions.map(option =>
+ option.id === optionId ? { ...option, label: newLabel } : option
+ );
+ setVoteOptions(updatedOptions);
+
+ // Save to server
+ const response = await fetch('/api/editable-text', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: 'vote-options',
+ content: updatedOptions
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const copyToClipboard = () => {
+ if (generatedLink) {
+ navigator.clipboard.writeText(generatedLink);
+ alert('Link in die Zwischenablage kopiert!');
+ }
+ };
+
+ const handleToggleMemberAuth = async () => {
+ setIsTogglingMemberAuth(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/toggle-member-auth', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Ändern der Einstellung');
+ }
+
+ setMemberAuthEnabled(data.memberAuthEnabled);
+
+ // Show success message
+ alert(data.message);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsTogglingMemberAuth(false);
+ }
+ };
+
+ const handleResetVotes = async () => {
+ // Show confirmation dialog
+ if (!confirm('Sind Sie sicher, dass Sie alle Abstimmungsdaten zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) {
+ return;
+ }
+
+ setIsResetting(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/reset-votes', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}), // No need to send password, using JWT cookie
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Zurücksetzen der Abstimmungsdaten');
+ }
+
+ // Show success message
+ alert('Abstimmungsdaten wurden erfolgreich zurückgesetzt.');
+
+ // If stats are currently shown, refresh them
+ if (showStats) {
+ handleGetStats();
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsResetting(false);
+ }
+ };
+
+ const handleGenerateBulkTokens = async () => {
+ setIsGeneratingBulk(true);
+ setError(null);
+
+ try {
+ // Generate the specified number of tokens
+ const response = await fetch('/api/generate-bulk-tokens', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ count: bulkTokenCount }),
+ });
+
+ if (!response.ok) {
+ // Try to parse error as JSON
+ try {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Fehler beim Generieren der Tokens');
+ } catch (jsonError) {
+ // If not JSON, use status text
+ throw new Error(`Fehler beim Generieren der Tokens: ${response.statusText}`);
+ }
+ }
+
+ // For successful responses, get the CSV data and create a download
+ const csvData = await response.text();
+ const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+
+ // Create a temporary link and trigger download
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', `abstimmungslinks_${new Date().toISOString().slice(0, 10)}.csv`);
+ document.body.appendChild(link);
+ link.click();
+
+ // Clean up
+ setTimeout(() => {
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ }, 100);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsGeneratingBulk(false);
+ }
+ };
+
+ // Login form if not authenticated
+ if (!isAuthenticated) {
+ return (
+
+ );
+ }
+
+ // WYSIWYG editor view
+ if (showEditor && selectedTextId) {
+ return (
+
+
+
TEXT BEARBEITEN
+
+
+
+
+ {selectedTextId === 'welcome-text' && 'Willkommenstext'}
+ {selectedTextId === 'current-vote-text' && 'Aktuelle Abstimmung Text'}
+ {selectedTextId === 'vote-question' && 'Abstimmungsfrage'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
ADMIN-BEREICH
+
+ {!showStats ? (
+
+
+
+
+
+
+
+
+
+
Texte bearbeiten
+
+
+
+
+
+
+
+
+
+
+
+
+
Abstimmungsoptionen
+
+
+
+ {showVoteOptions && (
+
+
Aktuelle Optionen
+
+
+ {voteOptions.map((option) => (
+
+
+ {option.id}: {option.label}
+
+
+
+ ))}
+
+
+
Neue Option hinzufügen
+
+
+
+
setNewOptionId(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
+ placeholder="option-id"
+ />
+
+ Die ID wird intern verwendet und sollte kurz und eindeutig sein.
+
+
+
+
+
+
setNewOptionLabel(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
+ placeholder="Anzeigename der Option"
+ />
+
+ Der Anzeigename wird den Abstimmenden angezeigt.
+
+
+
+
+
+
+
+
Hinweis:
+
Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.
+
+
+ )}
+
+
+
+
+
Mitgliederanmeldung
+
+
+
+
+
+ Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen.
+
+
+
+
+
+
+
+ {showMembers && (
+
+
+
+ )}
+
+
+
+
Abstimmungslinks
+
+
+
+
+
+
+
+ {generatedLink && (
+
+
Generierter Abstimmungslink:
+
+ {generatedLink}
+
+
+
+ )}
+
+
+ {error && (
+
{error}
+ )}
+
+
+
Mehrere Abstimmungslinks generieren:
+
+
+ setBulkTokenCount(Math.max(1, parseInt(e.target.value) || 1))}
+ className="w-24 px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
+ />
+
+
+
+ Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können.
+
+
+
+
+
+
+
Zurücksetzen
+
+
+
+
+ Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden.
+
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+ ) : (
+
+
Abstimmungsstatistiken
+
+ {stats && voteOptions.length > 0 && (
+
+
+ {voteOptions.map((option) => (
+
+
{stats[option.id] || 0}
+
{option.label}
+
+ ))}
+
+
+
+
{stats.total}
+
Gesamtstimmen
+
+
+ {stats.total > 0 && (
+
+
Ergebnisübersicht
+
+
+ {voteOptions.map((option, index) => {
+ const percentage = ((stats[option.id] || 0) / stats.total) * 100;
+ // Define colors for different options
+ const colors = [
+ 'bg-[#0057a6]', // Blue for first option (usually yes)
+ 'bg-red-500', // Red for second option (usually no)
+ 'bg-gray-500' // Gray for third option (usually abstain)
+ ];
+ // Use a default color for additional options
+ const color = index < colors.length ? colors[index] : 'bg-green-500';
+
+ return (
+
+ );
+ })}
+
+
+
+ {voteOptions.map((option, index) => {
+ const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100);
+ // Define text colors for different options
+ const textColors = [
+ 'text-[#0057a6]', // Blue for first option (usually yes)
+ 'text-red-600', // Red for second option (usually no)
+ 'text-gray-600' // Gray for third option (usually abstain)
+ ];
+ // Use a default color for additional options
+ const textColor = index < textColors.length ? textColors[index] : 'text-green-600';
+
+ return (
+
+ {percentage}% {option.label}
+
+ );
+ })}
+
+
+ )}
+
+ )}
+
+ {comments.length > 0 && (
+
+
Kommentare der Teilnehmer
+
+ {comments.map((comment, index) => (
+
+
+
+ {voteOptions.find(option => option.id === comment.vote)?.label || comment.vote}
+
+
+ {new Date(comment.timestamp).toLocaleDateString('de-DE')}
+
+
+
{comment.comment}
+
+ ))}
+
+
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/api/editable-text/route.ts b/src/app/api/editable-text/route.ts
new file mode 100644
index 0000000..3e853c4
--- /dev/null
+++ b/src/app/api/editable-text/route.ts
@@ -0,0 +1,131 @@
+import { checkAdminAuth } from '@/lib/auth';
+import fs from 'fs';
+import { NextRequest, NextResponse } from 'next/server';
+import path from 'path';
+
+// Path to the editable text file
+const TEXT_FILE = path.join(process.cwd(), 'data', 'editable_text.json');
+
+// Ensure the data directory exists
+function ensureDataDirectory() {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+ }
+}
+
+// Get all editable texts
+function getEditableTexts() {
+ ensureDataDirectory();
+
+ if (!fs.existsSync(TEXT_FILE)) {
+ // Default texts
+ const defaultTexts = [
+ {
+ id: 'welcome-text',
+ content: 'Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.
Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.
'
+ },
+ {
+ id: 'current-vote-text',
+ content: 'Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.
Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.
Bei Fragen wenden Sie sich bitte an den Vorstand.
'
+ },
+ {
+ id: 'vote-question',
+ content: 'Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?
'
+ },
+ {
+ id: 'vote-options',
+ content: [
+ {
+ id: 'yes',
+ label: 'Ja, ich stimme zu'
+ },
+ {
+ id: 'no',
+ label: 'Nein, ich stimme nicht zu'
+ },
+ {
+ id: 'abstain',
+ label: 'Ich enthalte mich'
+ }
+ ]
+ }
+ ];
+
+ fs.writeFileSync(TEXT_FILE, JSON.stringify(defaultTexts, null, 2));
+ return defaultTexts;
+ }
+
+ const data = fs.readFileSync(TEXT_FILE, 'utf-8');
+ return JSON.parse(data);
+}
+
+// Save editable texts
+function saveEditableTexts(texts: any[]) {
+ ensureDataDirectory();
+ fs.writeFileSync(TEXT_FILE, JSON.stringify(texts, null, 2));
+}
+
+// GET handler to retrieve all editable texts
+export async function GET() {
+ try {
+ const texts = getEditableTexts();
+ return NextResponse.json({ texts });
+ } catch (error) {
+ console.error('Error getting editable texts:', error);
+ return NextResponse.json(
+ { error: 'Failed to get editable texts' },
+ { status: 500 }
+ );
+ }
+}
+
+// POST handler to update an editable text (requires admin authentication)
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ // Check for admin auth
+ const { password, } = body;
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ const { id, content } = body;
+
+ if (!id || !content) {
+ return NextResponse.json(
+ { error: 'ID and content are required' },
+ { status: 400 }
+ );
+ }
+
+ // Get current texts
+ const texts = getEditableTexts();
+
+ // Find and update the text with the given ID
+ const textIndex = texts.findIndex((text: any) => text.id === id);
+
+ if (textIndex === -1) {
+ // Text not found, add new
+ texts.push({ id, content });
+ } else {
+ // Update existing text
+ texts[textIndex].content = content;
+ }
+
+ // Save updated texts
+ saveEditableTexts(texts);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Error updating editable text:', error);
+ return NextResponse.json(
+ { error: 'Failed to update editable text' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/generate-bulk-tokens/route.ts b/src/app/api/generate-bulk-tokens/route.ts
new file mode 100644
index 0000000..68b4532
--- /dev/null
+++ b/src/app/api/generate-bulk-tokens/route.ts
@@ -0,0 +1,57 @@
+import { checkAdminAuth, generateRandomToken } from '@/lib/auth';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ // Check for admin auth
+ const { password } = body;
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ // Parse request body
+ const count = parseInt(body.count) || 10;
+
+ // Limit the number of tokens that can be generated at once
+ if (count < 1 || count > 1000) {
+ return NextResponse.json(
+ { error: 'Count must be between 1 and 1000' },
+ { status: 400 }
+ );
+ }
+
+ // Generate tokens and URLs
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin;
+ const tokens = [];
+
+ for (let i = 0; i < count; i++) {
+ const token = await generateRandomToken();
+ const voteUrl = `${baseUrl}/vote?token=${encodeURIComponent(token)}`;
+ tokens.push({ token, voteUrl });
+ }
+
+ // Create CSV content
+ const csvHeader = 'Token,Abstimmungslink\n';
+ const csvRows = tokens.map(({ token, voteUrl }) => `"${token}","${voteUrl}"`).join('\n');
+ const csvContent = csvHeader + csvRows;
+
+ // Return CSV as text response
+ return new NextResponse(csvContent, {
+ headers: {
+ 'Content-Type': 'text/csv; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="abstimmungslinks_${new Date().toISOString().slice(0, 10)}.csv"`,
+ },
+ });
+ } catch (error) {
+ console.error('Error generating bulk tokens:', error);
+ return NextResponse.json(
+ { error: 'Failed to generate tokens' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/generate-token/route.ts b/src/app/api/generate-token/route.ts
new file mode 100644
index 0000000..172075f
--- /dev/null
+++ b/src/app/api/generate-token/route.ts
@@ -0,0 +1,35 @@
+import { checkAdminAuth, generateRandomToken } from '@/lib/auth';
+import { NextRequest, NextResponse } from 'next/server';
+
+// Get admin password from environment variable or use default for development
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'schafwaschener-segelverein-admin';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ // Check for admin auth
+ const { password } = body;
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ // Generate a new voting token
+ const token = await generateRandomToken();
+
+ // Create the voting URL with the token
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin;
+ const voteUrl = `${baseUrl}/vote?token=${encodeURIComponent(token)}`;
+
+ return NextResponse.json({ token, voteUrl });;
+ } catch (error) {
+ console.error('Error generating token:', error);
+ return NextResponse.json(
+ { error: 'Failed to generate token' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/member-login/route.ts b/src/app/api/member-login/route.ts
new file mode 100644
index 0000000..6fae366
--- /dev/null
+++ b/src/app/api/member-login/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { generateRandomToken } from '@/lib/auth';
+import { verifyMemberCredentials, getSettings } from '@/lib/server-auth';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Check if member authentication is enabled
+ const settings = getSettings();
+ if (!settings.memberAuthEnabled) {
+ return NextResponse.json(
+ { error: 'Mitgliederanmeldung ist derzeit deaktiviert' },
+ { status: 403 }
+ );
+ }
+
+ // Get credentials from request
+ const { memberNumber, password } = await request.json();
+
+ if (!memberNumber || !password) {
+ return NextResponse.json(
+ { error: 'Mitgliedsnummer und Passwort sind erforderlich' },
+ { status: 400 }
+ );
+ }
+
+ // Verify credentials
+ const { valid, hasVoted } = verifyMemberCredentials(memberNumber, password);
+
+ if (!valid) {
+ return NextResponse.json(
+ { error: 'Ungültige Mitgliedsnummer oder Passwort' },
+ { status: 401 }
+ );
+ }
+
+ // Check if member has already voted
+ if (hasVoted) {
+ return NextResponse.json(
+ { error: 'Sie haben bereits abgestimmt' },
+ { status: 403 }
+ );
+ }
+
+ // Generate a token for voting
+ const token = await generateRandomToken(memberNumber);
+
+ // Create the voting URL with the token
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin;
+ const voteUrl = `${baseUrl}/vote?token=${encodeURIComponent(token)}`;
+
+ // Create response with token cookie
+ const response = NextResponse.json({ token, voteUrl });
+
+ // Set the token cookie
+ response.cookies.set('vote-token', token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ maxAge: 60 * 60 * 24 * 7, // 7 days
+ path: '/',
+ });
+
+ return response;
+ } catch (error) {
+ console.error('Error during member login:', error);
+ return NextResponse.json(
+ { error: 'Fehler bei der Anmeldung' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/members/route.ts b/src/app/api/members/route.ts
new file mode 100644
index 0000000..8bdb6cf
--- /dev/null
+++ b/src/app/api/members/route.ts
@@ -0,0 +1,150 @@
+import { checkAdminAuth } from '@/lib/auth';
+import { addMember, deleteMember, getMemberCredentials, updateMember } from '@/lib/server-auth';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ try {
+ // Check for admin auth
+ let isAuthenticated = await checkAdminAuth();
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+
+ // Get all members
+ const members = getMemberCredentials();
+
+ // Remove password from response
+ const sanitizedMembers = members.map(({ password, ...rest }) => rest);
+
+ return NextResponse.json({ members: sanitizedMembers });
+ } catch (error) {
+ console.error('Error getting members:', error);
+ return NextResponse.json(
+ { error: 'Fehler beim Abrufen der Mitglieder' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ // Verify admin authentication
+ const body = await request.json();
+ // Check for admin auth
+ const { password } = body;
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ // Get the action and data from the request
+ const { action, ...data } = body;
+
+ if (!action) {
+ return NextResponse.json(
+ { error: 'Aktion ist erforderlich' },
+ { status: 400 }
+ );
+ }
+
+ // Handle different actions
+ switch (action) {
+ case 'add': {
+ const { memberNumber, password } = data;
+
+ if (!memberNumber || !password) {
+ return NextResponse.json(
+ { error: 'Mitgliedsnummer und Passwort sind erforderlich' },
+ { status: 400 }
+ );
+ }
+
+ const success = addMember(memberNumber, password);
+
+ if (!success) {
+ return NextResponse.json(
+ { error: 'Mitglied existiert bereits' },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: 'Mitglied erfolgreich hinzugefügt'
+ });
+ }
+
+ case 'update': {
+ const { memberNumber, password, hasVoted } = data;
+
+ if (!memberNumber) {
+ return NextResponse.json(
+ { error: 'Mitgliedsnummer ist erforderlich' },
+ { status: 400 }
+ );
+ }
+
+ const success = updateMember(memberNumber, {
+ password: password || undefined,
+ hasVoted: hasVoted !== undefined ? hasVoted : undefined
+ });
+
+ if (!success) {
+ return NextResponse.json(
+ { error: 'Mitglied nicht gefunden' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: 'Mitglied erfolgreich aktualisiert'
+ });
+ }
+
+ case 'delete': {
+ const { memberNumber } = data;
+
+ if (!memberNumber) {
+ return NextResponse.json(
+ { error: 'Mitgliedsnummer ist erforderlich' },
+ { status: 400 }
+ );
+ }
+
+ const success = deleteMember(memberNumber);
+
+ if (!success) {
+ return NextResponse.json(
+ { error: 'Mitglied nicht gefunden' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: 'Mitglied erfolgreich gelöscht'
+ });
+ }
+
+ default:
+ return NextResponse.json(
+ { error: 'Ungültige Aktion' },
+ { status: 400 }
+ );
+ }
+ } catch (error) {
+ console.error('Error managing members:', error);
+ return NextResponse.json(
+ { error: 'Fehler bei der Mitgliederverwaltung' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/public-stats/route.ts b/src/app/api/public-stats/route.ts
new file mode 100644
index 0000000..589637a
--- /dev/null
+++ b/src/app/api/public-stats/route.ts
@@ -0,0 +1,17 @@
+import { getSurveyStats } from '@/lib/survey';
+import { NextResponse } from 'next/server';
+
+export async function GET() {
+ try {
+ // Get survey statistics without requiring authentication
+ const stats = getSurveyStats();
+
+ return NextResponse.json({ stats });
+ } catch (error) {
+ console.error('Error getting public stats:', error);
+ return NextResponse.json(
+ { error: 'Failed to get survey statistics' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/reset-votes/route.ts b/src/app/api/reset-votes/route.ts
new file mode 100644
index 0000000..e764d53
--- /dev/null
+++ b/src/app/api/reset-votes/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { checkAdminAuth } from '@/lib/auth';
+import { resetMemberVotingStatus } from '@/lib/server-auth';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ // Check for admin auth
+ const { password } = body;
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ // Reset responses.json to an empty array
+ const responsesPath = path.join(process.cwd(), 'data', 'responses.json');
+ fs.writeFileSync(responsesPath, '[]', 'utf8');
+
+ // Reset used_tokens.json to an empty array
+ const usedTokensPath = path.join(process.cwd(), 'data', 'used_tokens.json');
+ fs.writeFileSync(usedTokensPath, '[]', 'utf8');
+
+ // Reset member voting status
+ resetMemberVotingStatus();
+
+ return NextResponse.json({
+ success: true,
+ message: 'Abstimmungsdaten wurden zurückgesetzt'
+ });
+
+ }
+ catch (error) {
+ console.error('Error resetting votes:', error);
+ return NextResponse.json(
+ { error: 'Fehler beim Zurücksetzen der Abstimmungsdaten' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
new file mode 100644
index 0000000..96da617
--- /dev/null
+++ b/src/app/api/settings/route.ts
@@ -0,0 +1,17 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSettings } from '@/lib/server-auth';
+
+export async function GET(request: NextRequest) {
+ try {
+ // Get settings
+ const settings = getSettings();
+
+ return NextResponse.json({ settings });
+ } catch (error) {
+ console.error('Error getting settings:', error);
+ return NextResponse.json(
+ { error: 'Fehler beim Abrufen der Einstellungen' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts
new file mode 100644
index 0000000..46acef2
--- /dev/null
+++ b/src/app/api/stats/route.ts
@@ -0,0 +1,41 @@
+import { checkAdminAuth } from '@/lib/auth';
+import { getAllResponses, getSurveyStats } from '@/lib/survey';
+import { NextRequest, NextResponse } from 'next/server';
+
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { password } = body;
+
+ // Check for admin auth
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+
+ // Get survey statistics and all responses
+ const stats = getSurveyStats();
+ const responses = getAllResponses();
+
+ // Filter out responses with comments, but don't include IDs to ensure anonymity
+ const comments = responses
+ .filter(response => response.comment && response.comment.trim() !== '')
+ .map(response => ({
+ vote: response.vote,
+ comment: response.comment,
+ timestamp: response.timestamp
+ }));
+
+ return NextResponse.json({ stats, comments });
+ } catch (error) {
+ console.error('Error getting stats:', error);
+ return NextResponse.json(
+ { error: 'Failed to get survey statistics' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/submit-vote/route.ts b/src/app/api/submit-vote/route.ts
new file mode 100644
index 0000000..13a9c5b
--- /dev/null
+++ b/src/app/api/submit-vote/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyToken } from '@/lib/server-auth';
+import { saveResponse, VoteOption, getVoteOptions } from '@/lib/survey';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Get the token from the request
+ const { token, vote, comment } = await request.json();
+
+ if (!token) {
+ return NextResponse.json(
+ { error: 'Token is required' },
+ { status: 400 }
+ );
+ }
+
+ // Get available vote options
+ const voteOptions = getVoteOptions();
+ const validOptionIds = voteOptions.map(option => option.id);
+
+ if (!vote || !validOptionIds.includes(vote)) {
+ return NextResponse.json(
+ { error: 'Valid vote option is required' },
+ { status: 400 }
+ );
+ }
+
+ // Verify the token
+ const { valid } = await verifyToken(token);
+
+ if (!valid) {
+ return NextResponse.json(
+ { error: 'Invalid or already used token' },
+ { status: 401 }
+ );
+ }
+
+ // Save the response
+ const response = saveResponse(vote as VoteOption, comment);
+
+ return NextResponse.json({ success: true, response });
+ } catch (error) {
+ console.error('Error submitting vote:', error);
+ return NextResponse.json(
+ { error: 'Failed to submit vote' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/toggle-member-auth/route.ts b/src/app/api/toggle-member-auth/route.ts
new file mode 100644
index 0000000..98e491f
--- /dev/null
+++ b/src/app/api/toggle-member-auth/route.ts
@@ -0,0 +1,43 @@
+import { checkAdminAuth } from '@/lib/auth';
+import { getSettings, updateSettings } from '@/lib/server-auth';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ // Check for admin auth
+ const { password } = body;
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+ // Get current settings
+ const settings = getSettings();
+
+ // Toggle the setting
+ const newSettings = {
+ ...settings,
+ memberAuthEnabled: !settings.memberAuthEnabled
+ };
+
+ // Update settings
+ updateSettings(newSettings);
+
+ return NextResponse.json({
+ success: true,
+ memberAuthEnabled: newSettings.memberAuthEnabled,
+ message: newSettings.memberAuthEnabled
+ ? 'Mitgliederanmeldung wurde aktiviert'
+ : 'Mitgliederanmeldung wurde deaktiviert'
+ });
+ } catch (error) {
+ console.error('Error toggling member auth:', error);
+ return NextResponse.json(
+ { error: 'Fehler beim Ändern der Einstellung' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/upload-members/route.ts b/src/app/api/upload-members/route.ts
new file mode 100644
index 0000000..20bb3b5
--- /dev/null
+++ b/src/app/api/upload-members/route.ts
@@ -0,0 +1,55 @@
+import { checkAdminAuth } from '@/lib/auth';
+import { importMembersFromCSV } from '@/lib/server-auth';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { password } = body;
+ // Check for admin auth
+ let isAuthenticated = await checkAdminAuth(password);
+ if (!isAuthenticated) {
+ return NextResponse.json(
+ { error: 'Unauthorized' },
+ { status: 401 }
+ );
+ }
+ // Get the CSV content from the request
+ const formData = await request.formData();
+ const file = formData.get('file') as File;
+
+ if (!file) {
+ return NextResponse.json(
+ { error: 'Keine Datei hochgeladen' },
+ { status: 400 }
+ );
+ }
+
+ // Check file type
+ if (!file.name.endsWith('.csv')) {
+ return NextResponse.json(
+ { error: 'Nur CSV-Dateien werden unterstützt' },
+ { status: 400 }
+ );
+ }
+
+ // Read the file content
+ const csvContent = await file.text();
+
+ // Import members from CSV
+ const result = importMembersFromCSV(csvContent);
+
+ return NextResponse.json({
+ success: true,
+ added: result.added,
+ skipped: result.skipped,
+ message: `${result.added} Mitglieder importiert, ${result.skipped} übersprungen`
+ });
+ } catch (error) {
+ console.error('Error uploading members:', error);
+ return NextResponse.json(
+ { error: 'Fehler beim Importieren der Mitglieder' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 947a048..2e8dd57 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -8,17 +8,81 @@
:root {
--background: #ffffff;
--foreground: #171717;
+ --ssvc-blue: #0057a6;
+ --ssvc-light-blue: #e6f0fa;
+ --ssvc-dark-blue: #003b70;
+ --ssvc-gray: #f0f0f0;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
-}
+/* Remove dark mode media query to prevent system dark mode from affecting the app */
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
+
+.ssvc-header {
+ background-color: var(--ssvc-blue);
+ color: white;
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.ssvc-logo-container {
+ display: flex;
+ align-items: center;
+}
+
+.ssvc-logo-text {
+ margin-left: 1rem;
+}
+
+.ssvc-main-content {
+ background-color: white;
+ padding: 2rem;
+ border-radius: 0;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+
+.ssvc-button {
+ background-color: var(--ssvc-blue);
+ color: white;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 0;
+ cursor: pointer;
+ font-weight: bold;
+ transition: background-color 0.3s;
+}
+
+.ssvc-button:hover {
+ background-color: var(--ssvc-dark-blue);
+}
+
+.ssvc-nav {
+ background-color: var(--ssvc-gray);
+ padding: 0.5rem 1rem;
+}
+
+.ssvc-nav a {
+ color: var(--ssvc-blue);
+ text-decoration: none;
+ padding: 0.5rem 1rem;
+ display: inline-block;
+ font-weight: bold;
+}
+
+.ssvc-nav a:hover {
+ background-color: var(--ssvc-light-blue);
+}
+
+.ssvc-footer {
+ margin-top: 2rem;
+ padding: 1rem;
+ text-align: center;
+ font-size: 0.875rem;
+ color: #666;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f7fa87e..354bb46 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
+import Link from "next/link";
+import Image from "next/image";
import "./globals.css";
const geistSans = Geist({
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING - Online Voting System",
+ description: "Secure online voting system for SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING club members",
};
export default function RootLayout({
@@ -23,11 +25,40 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
- {children}
+
+
+
+
+
SCHAFWASCHENER SEGELVEREIN
+
CHIEMSEE E.V.
+
RIMSTING
+
+
+
+
+
+ {children}
+
+
);
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..ca71256
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { EditableText } from '@/components/EditableText';
+
+export default function LoginPage() {
+ const router = useRouter();
+
+ const [memberNumber, setMemberNumber] = useState('');
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isEnabled, setIsEnabled] = useState(true);
+
+ // Check if member authentication is enabled
+ useEffect(() => {
+ const checkSettings = async () => {
+ try {
+ const response = await fetch('/api/member-login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ memberNumber: '', password: '' }),
+ });
+
+ if (response.status === 403) {
+ setIsEnabled(false);
+ }
+ } catch (err) {
+ // Silently fail, assume enabled
+ }
+ };
+
+ checkSettings();
+ }, []);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!memberNumber || !password) {
+ setError('Bitte geben Sie Ihre Mitgliedsnummer und Ihr Passwort ein');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/member-login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ memberNumber, password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Anmeldung fehlgeschlagen');
+ }
+
+ // Redirect to voting page with token
+ router.push(data.voteUrl);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (!isEnabled) {
+ return (
+
+
+
MITGLIEDERANMELDUNG
+
+
+
+
+ Die Mitgliederanmeldung ist derzeit deaktiviert.
+
+
+
+ Zurück zur Startseite
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
MITGLIEDERANMELDUNG
+
+
+
+
+
+
+
+
+
+
+ Zurück zur Startseite
+
+
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3eee014..3b2a43b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,101 +1,73 @@
-import Image from "next/image";
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { EditableText } from '@/components/EditableText';
export default function Home() {
+ const [memberAuthEnabled, setMemberAuthEnabled] = useState(false);
+
+ // Fetch settings on component mount
+ useEffect(() => {
+ const fetchSettings = async () => {
+ try {
+ const response = await fetch('/api/settings');
+ if (response.ok) {
+ const data = await response.json();
+ setMemberAuthEnabled(data.settings.memberAuthEnabled);
+ }
+ } catch (err) {
+ console.error('Error fetching settings:', err);
+ }
+ };
+
+ fetchSettings();
+ }, []);
return (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- - Save and see your changes instantly.
-
-
-
-
-
- Deploy now
-
-
- Read our docs
-
+
+
+
WILLKOMMEN BEI DER ONLINE-ABSTIMMUNG
+
+
+
+
+
ZUGANG ZUR ABSTIMMUNG
+
+ Wenn Sie einen Abstimmungslink erhalten haben, verwenden Sie diesen bitte direkt, um auf Ihren Stimmzettel zuzugreifen.
+
+
+ {memberAuthEnabled && (
+
+
+ Als Mitglied können Sie sich auch mit Ihrer Mitgliedsnummer und Ihrem Passwort anmelden.
+
+
+ ZUR MITGLIEDERANMELDUNG
+
+
+ )}
+
+
+
+ ABSTIMMUNGSERGEBNISSE
+
+
+
-
-
+
+
+
+
AKTUELLE ABSTIMMUNG
+
+
+
+
);
}
diff --git a/src/app/vote/page.tsx b/src/app/vote/page.tsx
new file mode 100644
index 0000000..1d78b14
--- /dev/null
+++ b/src/app/vote/page.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { EditableText } from '@/components/EditableText';
+import { VoteOption, VoteOptionConfig } from '@/lib/survey';
+
+export default function VotePage() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const token = searchParams.get('token');
+
+ const [voteOptions, setVoteOptions] = useState
([]);
+ const [selectedOption, setSelectedOption] = useState(null);
+ const [comment, setComment] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isSubmitted, setIsSubmitted] = useState(false);
+
+ // Fetch vote options
+ useEffect(() => {
+ const fetchVoteOptions = async () => {
+ try {
+ const response = await fetch('/api/editable-text');
+ if (response.ok) {
+ const data = await response.json();
+ if (data.texts && Array.isArray(data.texts)) {
+ const voteOptionsEntry = data.texts.find((text: any) => text.id === 'vote-options');
+ if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
+ setVoteOptions(voteOptionsEntry.content);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Error fetching vote options:', error);
+ // Use default options if fetch fails
+ setVoteOptions([
+ { id: 'yes', label: 'Ja, ich stimme zu' },
+ { id: 'no', label: 'Nein, ich stimme nicht zu' },
+ { id: 'abstain', label: 'Ich enthalte mich' }
+ ]);
+ }
+ };
+
+ fetchVoteOptions();
+ }, []);
+
+ // Redirect if no token is provided
+ useEffect(() => {
+ if (!token) {
+ router.push('/');
+ }
+ }, [token, router]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!selectedOption) {
+ setError('Bitte wählen Sie eine Option');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/submit-vote', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ token,
+ vote: selectedOption,
+ comment: comment.trim() || undefined,
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Übermitteln der Stimme');
+ }
+
+ setIsSubmitted(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (isSubmitted) {
+ return (
+
+
+
+
+
Vielen Dank!
+
+ Ihre Stimme wurde erfolgreich übermittelt.
+
+
+
+
+ Zurück zur Startseite
+
+
+
+ );
+ }
+
+ return (
+
+
+
SATZUNGSÄNDERUNG - ABSTIMMUNG
+
+
+
+
+ );
+}
diff --git a/src/components/EditableText.tsx b/src/components/EditableText.tsx
new file mode 100644
index 0000000..bb09b67
--- /dev/null
+++ b/src/components/EditableText.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface EditableTextProps {
+ id: string;
+}
+
+interface TextItem {
+ id: string;
+ content: string;
+}
+
+export function EditableText({ id }: EditableTextProps) {
+ const [content, setContent] = useState('');
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchText = async () => {
+ try {
+ setIsLoading(true);
+ const response = await fetch('/api/editable-text');
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.texts && Array.isArray(data.texts)) {
+ const textItem = data.texts.find((text: TextItem) => text.id === id);
+ if (textItem) {
+ setContent(textItem.content);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Error fetching editable text:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchText();
+ }, [id]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/MembersManager.tsx b/src/components/MembersManager.tsx
new file mode 100644
index 0000000..4adb6f0
--- /dev/null
+++ b/src/components/MembersManager.tsx
@@ -0,0 +1,537 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+
+interface Member {
+ memberNumber: string;
+ hasVoted: boolean;
+ lastLogin?: string;
+}
+
+export default function MembersManager() {
+ const [members, setMembers] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [showUploadForm, setShowUploadForm] = useState(false);
+ const [newMemberNumber, setNewMemberNumber] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [editingMember, setEditingMember] = useState(null);
+ const [editPassword, setEditPassword] = useState('');
+ const fileInputRef = useRef(null);
+
+ // Stats
+ const totalMembers = members.length;
+ const votedMembers = members.filter(m => m.hasVoted).length;
+ const notVotedMembers = totalMembers - votedMembers;
+
+ // Load members on component mount
+ useEffect(() => {
+ fetchMembers();
+ }, []);
+
+ // Fetch members from API
+ const fetchMembers = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch('/api/members');
+
+ if (!response.ok) {
+ throw new Error('Fehler beim Abrufen der Mitglieder');
+ }
+
+ const data = await response.json();
+ setMembers(data.members || []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Add a new member
+ const handleAddMember = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!newMemberNumber || !newPassword) {
+ setError('Mitgliedsnummer und Passwort sind erforderlich');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const response = await fetch('/api/members', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'add',
+ memberNumber: newMemberNumber,
+ password: newPassword
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Hinzufügen des Mitglieds');
+ }
+
+ setSuccess('Mitglied erfolgreich hinzugefügt');
+ setNewMemberNumber('');
+ setNewPassword('');
+ setShowAddForm(false);
+
+ // Refresh members list
+ fetchMembers();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Update a member
+ const handleUpdateMember = async (memberNumber: string) => {
+ if (!editPassword) {
+ setError('Passwort ist erforderlich');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const response = await fetch('/api/members', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'update',
+ memberNumber,
+ password: editPassword
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Aktualisieren des Mitglieds');
+ }
+
+ setSuccess('Passwort erfolgreich aktualisiert');
+ setEditPassword('');
+ setEditingMember(null);
+
+ // Refresh members list
+ fetchMembers();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Delete a member
+ const handleDeleteMember = async (memberNumber: string) => {
+ // Show confirmation dialog
+ if (!confirm(`Sind Sie sicher, dass Sie das Mitglied "${memberNumber}" löschen möchten?`)) {
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const response = await fetch('/api/members', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'delete',
+ memberNumber
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Löschen des Mitglieds');
+ }
+
+ setSuccess('Mitglied erfolgreich gelöscht');
+
+ // Refresh members list
+ fetchMembers();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Toggle voting status
+ const handleToggleVotingStatus = async (memberNumber: string, currentStatus: boolean) => {
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const response = await fetch('/api/members', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'update',
+ memberNumber,
+ hasVoted: !currentStatus
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Aktualisieren des Status');
+ }
+
+ setSuccess('Status erfolgreich aktualisiert');
+
+ // Refresh members list
+ fetchMembers();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Handle CSV upload
+ const handleUploadCSV = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!fileInputRef.current?.files?.length) {
+ setError('Bitte wählen Sie eine CSV-Datei aus');
+ return;
+ }
+
+ const file = fileInputRef.current.files[0];
+
+ if (!file.name.endsWith('.csv')) {
+ setError('Bitte wählen Sie eine CSV-Datei aus');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch('/api/upload-members', {
+ method: 'POST',
+ body: formData,
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Fehler beim Hochladen der Datei');
+ }
+
+ setSuccess(`${data.added} Mitglieder importiert, ${data.skipped} übersprungen`);
+ setShowUploadForm(false);
+
+ // Reset file input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+
+ // Refresh members list
+ fetchMembers();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Filter members by search term
+ const filteredMembers = members.filter(member =>
+ member.memberNumber.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+
+
Mitgliederverwaltung
+
+ {/* Stats */}
+
+
+
{totalMembers}
+
Gesamt
+
+
+
{votedMembers}
+
Abgestimmt
+
+
+
{notVotedMembers}
+
Nicht abgestimmt
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ {/* Add Member Form */}
+ {showAddForm && (
+
+
Neues Mitglied hinzufügen
+
+
+
+ )}
+
+ {/* Upload CSV Form */}
+ {showUploadForm && (
+
+
CSV-Datei importieren
+
+
+
+ )}
+
+ {/* Messages */}
+ {error && (
+
{error}
+ )}
+
+ {success && (
+
{success}
+ )}
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="Mitgliedsnummer eingeben..."
+ className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
+ />
+
+
+ {/* Members List */}
+ {isLoading && members.length === 0 ? (
+
Lade Mitglieder...
+ ) : filteredMembers.length === 0 ? (
+
+ {searchTerm ? 'Keine Mitglieder gefunden' : 'Keine Mitglieder vorhanden'}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/QuillEditor.tsx b/src/components/QuillEditor.tsx
new file mode 100644
index 0000000..ce54432
--- /dev/null
+++ b/src/components/QuillEditor.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { useState, useEffect, forwardRef } from 'react';
+import dynamic from 'next/dynamic';
+// Import ReactQuill dynamically to avoid SSR issues
+const ReactQuill = dynamic(() => import('react-quill-new'), {
+ ssr: false,
+ loading: () => Loading editor...
+});
+
+// Import styles
+import 'react-quill-new/dist/quill.snow.css';
+//import 'react-quill/dist/quill.snow.css';
+
+interface QuillEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+// Create a client-side only component
+const QuillEditor = ({ value, onChange, placeholder }: QuillEditorProps) => {
+ // Use state to track if component is mounted (client-side)
+ const [mounted, setMounted] = useState(false);
+
+ // Only render the editor on the client
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Default toolbar options
+ const modules = {
+ toolbar: [
+ [{ 'header': [1, 2, 3, false] }],
+ ['bold', 'italic', 'underline', 'strike'],
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }],
+ ['link'],
+ ['clean']
+ ]
+ };
+
+ if (!mounted) {
+ return Loading editor...
;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default QuillEditor;
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..7182abe
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,103 @@
+import { SignJWT, jwtVerify } from 'jose';
+import { v4 as uuidv4 } from 'uuid';
+import bcrypt from 'bcryptjs';
+import { cookies } from 'next/headers';
+
+// Interface for member credentials
+export interface MemberCredential {
+ memberNumber: string;
+ password: string; // Hashed password
+ hasVoted: boolean;
+ lastLogin?: string; // ISO date string
+}
+
+// These functions are now only used server-side in API routes
+// Client components should use the API routes instead
+
+// Generate a one-time use token for voting
+export async function generateRandomToken(memberNumber?: string): Promise {
+ // This function is used in API routes
+ const tokenId = uuidv4();
+
+ const payload: any = { tokenId };
+
+ // If memberNumber is provided, include it in the token
+ if (memberNumber) {
+ payload.memberNumber = memberNumber;
+ }
+
+ // Use a secret key from environment variable or a default one
+ const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
+ const encoder = new TextEncoder();
+ const key = encoder.encode(secretKey);
+
+ const token = await new SignJWT(payload)
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt()
+ .setExpirationTime('7d') // Token expires in 7 days
+ .sign(key);
+
+ return token;
+}
+
+// Generate an admin token
+async function generateAdminToken(): Promise {
+ // Use a secret key from environment variable or a default one
+ const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
+ const encoder = new TextEncoder();
+ const key = encoder.encode(secretKey);
+
+ const token = await new SignJWT({ role: 'admin' })
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt()
+ .setExpirationTime('24h') // Admin token expires in 24 hours
+ .sign(key);
+
+ return token;
+}
+
+// Verify an admin token
+async function verifyAdminToken(): Promise {
+ const token = (await cookies()).get('admin-token')?.value;
+ if (!token) return false;
+
+ try {
+ // Use a secret key from environment variable or a default one
+ const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
+ const encoder = new TextEncoder();
+ const key = encoder.encode(secretKey);
+
+ const { payload } = await jwtVerify(token, key);
+ return payload.role === 'admin';
+ } catch (error) {
+ console.error('Admin token verification failed:', error);
+ return false;
+ }
+}
+
+export async function checkAdminAuth(password?: string): Promise {
+ // Get admin password from environment variable or use default for development
+ let isAuthenticated = await verifyAdminToken();
+ if (isAuthenticated) return true;
+ if (password) {
+ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'schafwaschener-segelverein-admin';
+ if (password === ADMIN_PASSWORD) {
+ // Password is correct, generate and set admin token
+ const newAdminToken = await generateAdminToken();
+ (await cookies()).set('admin-token', newAdminToken);
+ return true;
+ }
+ }
+ return false;
+}
+
+
+// Hash a password
+export function hashPassword(password: string): string {
+ return bcrypt.hashSync(password, 10);
+}
+
+// Compare a password with a hash
+export function comparePassword(password: string, hash: string): boolean {
+ return bcrypt.compareSync(password, hash);
+}
diff --git a/src/lib/server-auth.ts b/src/lib/server-auth.ts
new file mode 100644
index 0000000..407cfc0
--- /dev/null
+++ b/src/lib/server-auth.ts
@@ -0,0 +1,321 @@
+import fs from 'fs';
+import path from 'path';
+import { SignJWT, jwtVerify } from 'jose';
+import { v4 as uuidv4 } from 'uuid';
+import crypto from 'crypto';
+import { MemberCredential, comparePassword, hashPassword } from './auth';
+
+// Keys for JWT signing and verification
+let privateKey: Uint8Array | null = null;
+let publicKey: Uint8Array | null = null;
+
+// Path to store the keypair
+const KEYS_FILE = path.join(process.cwd(), 'data', 'jwt_keys.json');
+
+// Path to the blacklist file
+const BLACKLIST_FILE = path.join(process.cwd(), 'data', 'used_tokens.json');
+
+// Path to the member credentials file
+const MEMBER_CREDENTIALS_FILE = path.join(process.cwd(), 'data', 'member_credentials.json');
+
+// Path to the settings file
+const SETTINGS_FILE = path.join(process.cwd(), 'data', 'settings.json');
+
+// Ensure the data directory exists
+function ensureDataDirectory() {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+ }
+}
+
+// Get all used tokens from the blacklist file
+export function getUsedTokens(): string[] {
+ ensureDataDirectory();
+
+ if (!fs.existsSync(BLACKLIST_FILE)) {
+ return [];
+ }
+
+ const data = fs.readFileSync(BLACKLIST_FILE, 'utf-8');
+ return JSON.parse(data);
+}
+
+// Add a token to the blacklist
+export function addToBlacklist(tokenId: string): void {
+ const usedTokens = getUsedTokens();
+
+ if (!usedTokens.includes(tokenId)) {
+ usedTokens.push(tokenId);
+
+ ensureDataDirectory();
+ fs.writeFileSync(BLACKLIST_FILE, JSON.stringify(usedTokens, null, 2));
+ }
+}
+
+// Get settings
+export function getSettings(): { memberAuthEnabled: boolean } {
+ ensureDataDirectory();
+
+ if (!fs.existsSync(SETTINGS_FILE)) {
+ const defaultSettings = { memberAuthEnabled: false };
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(defaultSettings, null, 2));
+ return defaultSettings;
+ }
+
+ const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
+ return JSON.parse(data);
+}
+
+// Update settings
+export function updateSettings(settings: { memberAuthEnabled: boolean }): void {
+ ensureDataDirectory();
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
+}
+
+// Get all member credentials
+export function getMemberCredentials(): MemberCredential[] {
+ ensureDataDirectory();
+
+ if (!fs.existsSync(MEMBER_CREDENTIALS_FILE)) {
+ fs.writeFileSync(MEMBER_CREDENTIALS_FILE, JSON.stringify([], null, 2));
+ return [];
+ }
+
+ const data = fs.readFileSync(MEMBER_CREDENTIALS_FILE, 'utf-8');
+ return JSON.parse(data);
+}
+
+// Save member credentials
+export function saveMemberCredentials(credentials: MemberCredential[]): void {
+ ensureDataDirectory();
+ fs.writeFileSync(MEMBER_CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
+}
+
+// Add a new member
+export function addMember(memberNumber: string, password: string): boolean {
+ const members = getMemberCredentials();
+
+ // Check if member already exists
+ if (members.some(m => m.memberNumber === memberNumber)) {
+ return false;
+ }
+
+ // Hash the password
+ const hashedPassword = hashPassword(password);
+
+ // Add the new member
+ members.push({
+ memberNumber,
+ password: hashedPassword,
+ hasVoted: false
+ });
+
+ saveMemberCredentials(members);
+ return true;
+}
+
+// Update a member
+export function updateMember(memberNumber: string, data: { password?: string, hasVoted?: boolean }): boolean {
+ const members = getMemberCredentials();
+ const memberIndex = members.findIndex(m => m.memberNumber === memberNumber);
+
+ if (memberIndex === -1) {
+ return false;
+ }
+
+ if (data.password) {
+ members[memberIndex].password = hashPassword(data.password);
+ }
+
+ if (data.hasVoted !== undefined) {
+ members[memberIndex].hasVoted = data.hasVoted;
+ }
+
+ saveMemberCredentials(members);
+ return true;
+}
+
+// Delete a member
+export function deleteMember(memberNumber: string): boolean {
+ const members = getMemberCredentials();
+ const initialLength = members.length;
+
+ const filteredMembers = members.filter(m => m.memberNumber !== memberNumber);
+
+ if (filteredMembers.length === initialLength) {
+ return false;
+ }
+
+ saveMemberCredentials(filteredMembers);
+ return true;
+}
+
+// Import members from CSV content
+export function importMembersFromCSV(csvContent: string): { added: number, skipped: number } {
+ const members = getMemberCredentials();
+ const existingMemberNumbers = new Set(members.map(m => m.memberNumber));
+
+ let added = 0;
+ let skipped = 0;
+
+ // Parse CSV content
+ const lines = csvContent.split('\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ // Skip header row if present
+ if (i === 0 && (line.toLowerCase().includes('member') || line.toLowerCase().includes('password'))) {
+ continue;
+ }
+
+ // Split by comma, semicolon, or tab
+ const parts = line.split(/[,;\t]/);
+
+ if (parts.length >= 2) {
+ const memberNumber = parts[0].trim();
+ const password = parts[1].trim();
+
+ // Skip if member number already exists
+ if (existingMemberNumbers.has(memberNumber)) {
+ skipped++;
+ continue;
+ }
+
+ // Hash the password
+ const hashedPassword = hashPassword(password);
+
+ // Add the new member
+ members.push({
+ memberNumber,
+ password: hashedPassword,
+ hasVoted: false
+ });
+
+ existingMemberNumbers.add(memberNumber);
+ added++;
+ }
+ }
+
+ saveMemberCredentials(members);
+ return { added, skipped };
+}
+
+// Verify member credentials
+export function verifyMemberCredentials(memberNumber: string, password: string): { valid: boolean, hasVoted: boolean } {
+ const members = getMemberCredentials();
+ const member = members.find(m => m.memberNumber === memberNumber);
+
+ if (!member) {
+ return { valid: false, hasVoted: false };
+ }
+
+ const passwordValid = comparePassword(password, member.password);
+
+ if (passwordValid) {
+ // Update last login time
+ member.lastLogin = new Date().toISOString();
+ saveMemberCredentials(members);
+ }
+
+ return { valid: passwordValid, hasVoted: member.hasVoted };
+}
+
+// Mark member as voted
+export function markMemberAsVoted(memberNumber: string): boolean {
+ return updateMember(memberNumber, { hasVoted: true });
+}
+
+// Reset all member voting status
+export function resetMemberVotingStatus(): void {
+ const members = getMemberCredentials();
+
+ for (const member of members) {
+ member.hasVoted = false;
+ }
+
+ saveMemberCredentials(members);
+}
+
+// Function to generate or load keys
+export async function ensureKeys() {
+ if (!privateKey || !publicKey) {
+ try {
+ // Check if keys exist in the data directory
+ if (fs.existsSync(KEYS_FILE)) {
+ // Load keys from file
+ const keysData = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
+ const encoder = new TextEncoder();
+ privateKey = encoder.encode(keysData.privateKey);
+ publicKey = encoder.encode(keysData.publicKey);
+ } else {
+ // Check if secret key is provided in environment variable
+ if (process.env.JWT_SECRET_KEY) {
+ // Use the provided secret key
+ const encoder = new TextEncoder();
+ const secretKey = process.env.JWT_SECRET_KEY;
+ privateKey = encoder.encode(secretKey);
+ publicKey = encoder.encode(secretKey);
+ } else {
+ // Generate a random secret key
+ const secretKey = crypto.randomBytes(32).toString('hex');
+ const encoder = new TextEncoder();
+ privateKey = encoder.encode(secretKey);
+ publicKey = encoder.encode(secretKey);
+ }
+
+ // Save keys to file
+ ensureDataDirectory();
+ fs.writeFileSync(KEYS_FILE, JSON.stringify({
+ privateKey: new TextDecoder().decode(privateKey),
+ publicKey: new TextDecoder().decode(publicKey)
+ }, null, 2));
+ }
+ } catch (error) {
+ console.error('Error handling JWT keys:', error);
+ // Fallback to a default key in case of error
+ const encoder = new TextEncoder();
+ const secretKey = 'schafwaschener-segelverein-secret-key-for-jwt-signing';
+ privateKey = encoder.encode(secretKey);
+ publicKey = encoder.encode(secretKey);
+ }
+ }
+}
+
+// Verify a token and mark it as used
+export async function verifyToken(token: string): Promise<{ valid: boolean, memberNumber?: string }> {
+ try {
+ // Use a secret key from environment variable or a default one
+ const secretKey = process.env.JWT_SECRET_KEY || 'schafwaschener-segelverein-secret-key-for-jwt-signing';
+ const encoder = new TextEncoder();
+ const key = encoder.encode(secretKey);
+
+ const { payload } = await jwtVerify(token, key);
+ const tokenId = payload.tokenId as string;
+ const memberNumber = payload.memberNumber as string | undefined;
+
+ // Check if token has been used before
+ const usedTokens = getUsedTokens();
+ if (usedTokens.includes(tokenId)) {
+ return { valid: false };
+ }
+
+ // Mark token as used by adding to blacklist
+ addToBlacklist(tokenId);
+
+ // If token contains a member number, mark that member as voted
+ if (memberNumber) {
+ markMemberAsVoted(memberNumber);
+ }
+
+ return { valid: true, memberNumber };
+ } catch (error) {
+ console.error('Token verification failed:', error);
+ return { valid: false };
+ }
+}
+
+// These functions are removed because they're causing issues
+// We'll handle cookies directly in the API routes
diff --git a/src/lib/survey.ts b/src/lib/survey.ts
new file mode 100644
index 0000000..1184540
--- /dev/null
+++ b/src/lib/survey.ts
@@ -0,0 +1,128 @@
+import fs from 'fs';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
+
+// Define the vote option interface
+export interface VoteOptionConfig {
+ id: string;
+ label: string;
+}
+
+// Define the survey response type
+export type VoteOption = string; // Now a string that matches the option id
+
+export interface SurveyResponse {
+ id: string; // Used only for internal tracking, not associated with the voter
+ vote: VoteOption;
+ comment?: string;
+ timestamp: string;
+}
+
+// Path to the data files
+const DATA_FILE = path.join(process.cwd(), 'data', 'responses.json');
+const TEXT_FILE = path.join(process.cwd(), 'data', 'editable_text.json');
+
+// Ensure the data directory exists
+function ensureDataDirectory() {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+ }
+}
+
+// Get vote options from editable_text.json
+export function getVoteOptions(): VoteOptionConfig[] {
+ ensureDataDirectory();
+
+ if (!fs.existsSync(TEXT_FILE)) {
+ // Default options if file doesn't exist
+ return [
+ { id: 'yes', label: 'Ja, ich stimme zu' },
+ { id: 'no', label: 'Nein, ich stimme nicht zu' },
+ { id: 'abstain', label: 'Ich enthalte mich' }
+ ];
+ }
+
+ try {
+ const data = fs.readFileSync(TEXT_FILE, 'utf-8');
+ const texts = JSON.parse(data);
+
+ const voteOptionsEntry = texts.find((text: any) => text.id === 'vote-options');
+
+ if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) {
+ return voteOptionsEntry.content;
+ }
+
+ // Return default options if not found or invalid
+ return [
+ { id: 'yes', label: 'Ja, ich stimme zu' },
+ { id: 'no', label: 'Nein, ich stimme nicht zu' },
+ { id: 'abstain', label: 'Ich enthalte mich' }
+ ];
+ } catch (error) {
+ console.error('Error reading vote options:', error);
+ // Return default options on error
+ return [
+ { id: 'yes', label: 'Ja, ich stimme zu' },
+ { id: 'no', label: 'Nein, ich stimme nicht zu' },
+ { id: 'abstain', label: 'Ich enthalte mich' }
+ ];
+ }
+}
+
+// Get all survey responses
+export function getAllResponses(): SurveyResponse[] {
+ ensureDataDirectory();
+
+ if (!fs.existsSync(DATA_FILE)) {
+ return [];
+ }
+
+ const data = fs.readFileSync(DATA_FILE, 'utf-8');
+ return JSON.parse(data);
+}
+
+// Save a new survey response
+export function saveResponse(vote: VoteOption, comment?: string): SurveyResponse {
+ const responses = getAllResponses();
+
+ // Create a new response with a random ID (not associated with the voter)
+ const newResponse: SurveyResponse = {
+ id: uuidv4(), // Random ID only for internal tracking
+ vote,
+ comment,
+ timestamp: new Date().toISOString(),
+ };
+
+ responses.push(newResponse);
+
+ ensureDataDirectory();
+ fs.writeFileSync(DATA_FILE, JSON.stringify(responses, null, 2));
+
+ return newResponse;
+}
+
+// Get survey statistics
+export function getSurveyStats() {
+ const responses = getAllResponses();
+ const voteOptions = getVoteOptions();
+
+ // Initialize stats object with total count
+ const stats: Record = {
+ total: responses.length
+ };
+
+ // Initialize count for each vote option
+ voteOptions.forEach(option => {
+ stats[option.id] = 0;
+ });
+
+ // Count votes for each option
+ responses.forEach(response => {
+ if (stats[response.vote] !== undefined) {
+ stats[response.vote]++;
+ }
+ });
+
+ return stats;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..f69e600
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ // This middleware doesn't do anything special yet
+ // It's just a placeholder for future authentication middleware
+ return NextResponse.next();
+}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..8ede5d3
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,13 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ darkMode: false, // Disable dark mode
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}