initial version

This commit is contained in:
Jean Jacques Avril 2025-03-02 17:12:22 +00:00
parent c0f3deeb27
commit 86d26a1e41
42 changed files with 4051 additions and 132 deletions

34
.dockerignore Normal file
View File

@ -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

55
Dockerfile Normal file
View File

@ -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"]

134
README.md
View File

@ -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

26
build-docker.sh Executable file
View File

@ -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

35
data/editable_text.json Normal file
View File

@ -0,0 +1,35 @@
[
{
"id": "welcome-text",
"content": "<p>Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.</p><p>Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.</p>"
},
{
"id": "current-vote-text",
"content": "<p>Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>"
},
{
"id": "vote-question",
"content": "<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>"
},
{
"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"
}
]
}
]

4
data/jwt_keys.json Normal file
View File

@ -0,0 +1,4 @@
{
"privateKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264",
"publicKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264"
}

View File

@ -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"
}
]

1
data/responses.json Normal file
View File

@ -0,0 +1 @@
[]

3
data/settings.json Normal file
View File

@ -0,0 +1,3 @@
{
"memberAuthEnabled": true
}

1
data/used_tokens.json Normal file
View File

@ -0,0 +1 @@
[]

18
docker-compose.yml Normal file
View File

@ -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:-}

131
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

2
public/ssvc-logo.svg Normal file
View File

@ -0,0 +1,2 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 92.82 47.76" width="190" height="98"><defs><clipPath id="clip-path" transform="translate(0.04 -0.03)"><rect width="92.83" height="48.14" style="fill:none"/></clipPath></defs><g id="Ebene_2" data-name="Ebene 2"><g id="Ebene_1-2" data-name="Ebene 1"><g style="clip-path:url(#clip-path)"><path d="M0,0,92.78,24,0,47.79Z" transform="translate(0.04 -0.03)" style="fill:#00519e;fill-rule:evenodd"/><path d="M24,9.68c11.08,0,20,6.37,20,14.23S35,38.22,24,38.22,4,31.76,4,23.91,13,9.68,24,9.68Z" transform="translate(0.04 -0.03)" style="fill:#fff;fill-rule:evenodd"/><path d="M35.42,25.12s-.51,2.28-1,3.93a29.53,29.53,0,0,1-1.65,3.82l-3.29,0-.27,1.14-7.24,0,.43-1.28H19.44L19.17,34H12l3.1-8.93-10.72.22C5.52,32.4,13.78,37.93,24,37.93s18.64-5.51,19.65-12.57C43.54,25.34,35.42,25.12,35.42,25.12Z" transform="translate(0.04 -0.03)" style="fill:#00519e;fill-rule:evenodd"/><path d="M19.42,27c0,.93-.6,5.18-.6,5.18H16.14s.59-5,.72-6.24S18,19.75,18,19.75a27.16,27.16,0,0,1,1.05,3.13A21.67,21.67,0,0,1,19.42,27Z" transform="translate(0.04 -0.03)" style="fill:#00519e"/><path d="M29.48,25.58a28.74,28.74,0,0,1-.57,6.62l-2.9,0s.54-4.82.86-6.86S28,19,28,19A53.13,53.13,0,0,1,29.48,25.58Z" transform="translate(0.04 -0.03)" style="fill:#00519e"/><path d="M35,18.63C34.47,13.22,30.77,12,30.77,12A45.35,45.35,0,0,0,28.1,19.1c.26.81.8,2.7,1.08,4.12.59,2.93,0,7-.35,9l3.45,0A25.86,25.86,0,0,0,35,18.63ZM27.41,22c.2-1,.44-2,.69-2.87a3.35,3.35,0,0,0-.11-.38L22.9,33.48l5.69,0s.1-.46.24-1.2l-2.88,0A95.75,95.75,0,0,1,27.41,22Zm-9.82,0c.17-.87.37-1.72.59-2.52C18,19,18,18.72,18,18.72L12.87,33.48l5.69,0s.11-.47.24-1.23l-2.65,0S16.53,27.36,17.59,22ZM25,18.62c-.54-5.4-4.13-6.64-4.13-6.64a46.18,46.18,0,0,0-2.66,7.43c.27.91.74,2.54,1,3.79.59,2.92,0,7-.35,9l3.51,0A26.35,26.35,0,0,0,25,18.62Z" transform="translate(0.04 -0.03)" style="fill:#d5edfa;fill-rule:evenodd"/><path d="M35,18.63C34.47,13.22,30.77,12,30.77,12A45.35,45.35,0,0,0,28.1,19.1c.26.81.8,2.7,1.08,4.12.59,2.93,0,7-.35,9l3.45,0A25.86,25.86,0,0,0,35,18.63ZM27.41,22c.2-1,.44-2,.69-2.87a3.35,3.35,0,0,0-.11-.38L22.9,33.48l5.69,0s.1-.46.24-1.2l-2.88,0A95.75,95.75,0,0,1,27.41,22Zm-9.82,0c.17-.87.37-1.72.59-2.52C18,19,18,18.72,18,18.72L12.87,33.48l5.69,0s.11-.47.24-1.23l-2.65,0S16.53,27.36,17.59,22ZM25,18.62c-.54-5.4-4.13-6.64-4.13-6.64a46.18,46.18,0,0,0-2.66,7.43c.27.91.74,2.54,1,3.79.59,2.92,0,7-.35,9l3.51,0A26.35,26.35,0,0,0,25,18.62Z" transform="translate(0.04 -0.03)" style="fill:none;stroke:#00519e;stroke-miterlimit:2.104000002145767;stroke-width:0.4px"/><path d="M52.54,22c0,.19,0,.27-.17.27s-.22-.12-.4-.67c-.31-1-.56-1.25-1.13-1.25a1.11,1.11,0,0,0-1.12,1.2,1.45,1.45,0,0,0,.44,1,7.7,7.7,0,0,0,1.27.94,3.82,3.82,0,0,1,1,.91,2.27,2.27,0,0,1,.46,1.35A2.36,2.36,0,0,1,50.4,28a3,3,0,0,1-1.5-.34c-.15-.08-.19-.2-.19-.5V25.71c0-.18.09-.29.24-.29s.18.07.29.44c.35,1.28.8,1.81,1.56,1.81A1.26,1.26,0,0,0,52,26.32a1.73,1.73,0,0,0-.43-1.11,3.54,3.54,0,0,0-.63-.54l-.27-.21-.28-.18-.27-.19A2.57,2.57,0,0,1,48.76,22,2.05,2.05,0,0,1,51,20a4.38,4.38,0,0,1,1.32.21c.2.06.27.17.27.42V22Z" transform="translate(0.04 -0.03)" style="fill:#fff"/><path d="M57.56,22c0,.19-.05.27-.17.27s-.22-.12-.4-.67c-.31-1-.56-1.25-1.13-1.25a1.1,1.1,0,0,0-1.11,1.2,1.44,1.44,0,0,0,.43,1,7.7,7.7,0,0,0,1.27.94,3.82,3.82,0,0,1,1,.91A2.2,2.2,0,0,1,58,25.73,2.36,2.36,0,0,1,55.42,28a3,3,0,0,1-1.5-.34c-.15-.08-.18-.2-.18-.5V25.71c0-.18.08-.29.23-.29s.19.07.29.44c.35,1.28.81,1.81,1.57,1.81a1.27,1.27,0,0,0,1.23-1.35,1.68,1.68,0,0,0-.44-1.11,3.54,3.54,0,0,0-.63-.54l-.27-.21-.28-.18-.27-.19A2.56,2.56,0,0,1,53.79,22,2,2,0,0,1,56,20a4.37,4.37,0,0,1,1.31.21c.2.06.27.17.27.42V22Z" transform="translate(0.04 -0.03)" style="fill:#fff"/><path d="M63.19,21.38a2.14,2.14,0,0,0,.08-.54c0-.27-.11-.35-.47-.35s-.3,0-.3-.19.07-.15.2-.15a.66.66,0,0,1,.17,0h1.48c.17,0,.22,0,.22.15s0,.17-.37.18a.55.55,0,0,0-.45.37c0,.16-.14.36-.22.63l-.09.27-.08.27-.09.27-1.21,4s-.07.25-.15.56l0,.2,0,.2c-.07.27,0,0-.15.63-.07.25-.12.32-.24.32s-.17-.09-.24-.41a3.12,3.12,0,0,0-.1-.37c0-.25-.13-.52-.2-.81s-.2-.71-.25-.89l-1.42-4.48c-.2-.65-.27-.71-.76-.75a.16.16,0,0,1-.16-.16c0-.12.06-.17.21-.17h.7l.28,0h.57a4.19,4.19,0,0,0,.54,0h.21c.15,0,.23,0,.23.17s-.1.18-.3.16-.46.09-.46.24a2.13,2.13,0,0,0,.09.42l1.45,4.64Z" transform="translate(0.04 -0.03)" style="fill:#fff"/><path d="M69.69,22c0,.3,0,.37-.21.37s-.26-.15-.39-.57c-.37-1.15-.66-1.49-1.27-1.49a1.63,1.63,0,0,0-1.41.78,5.34,5.34,0,0,0-.69,3,4.85,4.85,0,0,0,.62,2.73,1.8,1.8,0,0,0,1.45.94,1.11,1.11,0,0,0,.93-.49,2.34,2.34,0,0,0,.35-.92c.19-.69.3-.91.49-.93s.2.12.2.46v1.38c0,.27,0,.3-.14.38a3.17,3.17,0,0,1-1.75.46c-2,0-3.42-1.69-3.42-4S65.89,20,67.84,20a4.52,4.52,0,0,1,1.64.34c.18.07.21.12.23.37Z" transform="translate(0.04 -0.03)" style="fill:#fff"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

42
run-docker.sh Executable file
View File

@ -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}"

168
src/app/abstimmung/page.tsx Normal file
View File

@ -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<Stats | null>(null);
const [voteOptions, setVoteOptions] = useState<VoteOptionConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="ssvc-main-content text-center">
<p>Lade Ergebnisse...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="ssvc-main-content">
<div className="text-red-500 mb-4">{error}</div>
<Link href="/" className="ssvc-button inline-block">
Zurück zur Startseite
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ABSTIMMUNGSERGEBNISSE</h1>
<div className="ssvc-main-content">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Aktuelle Ergebnisse der Satzungsänderung</h2>
{stats && voteOptions.length > 0 && (
<div className="space-y-4">
<div className={`grid ${voteOptions.length === 1 ? 'grid-cols-1' : voteOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
{voteOptions.map((option) => (
<div key={option.id} className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{stats[option.id] || 0}</div>
<div className="text-sm text-[#0057a6]">{option.label}</div>
</div>
))}
</div>
<div className="bg-[#0057a6] p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-white">Gesamtstimmen</div>
</div>
{stats.total > 0 && (
<div className="mt-4">
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3>
<div className="h-6 bg-gray-200 overflow-hidden">
<div className="flex h-full">
{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 (
<div
key={option.id}
className={`${color} h-full`}
style={{ width: `${percentage}%` }}
></div>
);
})}
</div>
</div>
<div className="flex justify-between text-xs mt-1">
{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 (
<span key={option.id} className={textColor}>
{percentage}% {option.label}
</span>
);
})}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

948
src/app/admin/page.tsx Normal file
View File

@ -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<string | null>(null);
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [bulkTokenCount, setBulkTokenCount] = useState<number>(10);
const [isGeneratingBulk, setIsGeneratingBulk] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [showStats, setShowStats] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [showEditor, setShowEditor] = useState(false);
const [editableTexts, setEditableTexts] = useState<EditableText[]>([
{ id: 'welcome-text', content: '<p>Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.</p><p>Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.</p>' },
{ id: 'current-vote-text', content: '<p>Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>' },
{ id: 'vote-question', content: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>' }
]);
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<string | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ADMIN-BEREICH</h1>
<div className="ssvc-main-content">
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Admin-Passwort
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</div>
);
}
// WYSIWYG editor view
if (showEditor && selectedTextId) {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">TEXT BEARBEITEN</h1>
<div className="ssvc-main-content">
<div className="mb-4">
<h2 className="text-lg font-medium text-[#0057a6] mb-2">
{selectedTextId === 'welcome-text' && 'Willkommenstext'}
{selectedTextId === 'current-vote-text' && 'Aktuelle Abstimmung Text'}
{selectedTextId === 'vote-question' && 'Abstimmungsfrage'}
</h2>
<div className="mb-4">
<QuillEditor
value={editorContent}
onChange={setEditorContent}
placeholder="Enter content here..."
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveText}
className="ssvc-button flex-1"
>
Speichern
</button>
<button
onClick={() => setShowEditor(false)}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 transition-colors duration-300"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ADMIN-BEREICH</h1>
{!showStats ? (
<div className="ssvc-main-content">
<div className="flex gap-2 mb-4">
<button
onClick={handleGetStats}
disabled={isLoading}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 transition-colors duration-300 disabled:opacity-50"
>
Statistiken anzeigen
</button>
</div>
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Texte bearbeiten</h2>
<div className="space-y-3">
<button
onClick={() => handleEditText('welcome-text')}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
Willkommenstext bearbeiten
</button>
<button
onClick={() => handleEditText('current-vote-text')}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
Aktuelle Abstimmung Text bearbeiten
</button>
<button
onClick={() => handleEditText('vote-question')}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
Abstimmungsfrage bearbeiten
</button>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#0057a6]">Abstimmungsoptionen</h2>
<button
onClick={() => setShowVoteOptions(!showVoteOptions)}
className="text-sm text-[#0057a6] hover:underline"
>
{showVoteOptions ? 'Ausblenden' : 'Bearbeiten'}
</button>
</div>
{showVoteOptions && (
<div className="bg-[#e6f0fa] p-4 mb-4">
<h3 className="font-medium text-[#0057a6] mb-3">Aktuelle Optionen</h3>
<div className="space-y-2 mb-4">
{voteOptions.map((option) => (
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
<div>
<span className="font-medium">{option.id}:</span> {option.label}
</div>
<button
onClick={() => handleRemoveVoteOption(option.id)}
className="text-red-600 hover:text-red-800"
title="Option entfernen"
>
</button>
</div>
))}
</div>
<h3 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h3>
<div className="space-y-3">
<div>
<label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1">
Option ID (z.B. "yes", "no")
</label>
<input
type="text"
id="newOptionId"
value={newOptionId}
onChange={(e) => setNewOptionId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
placeholder="option-id"
/>
<p className="text-xs text-gray-500 mt-1">
Die ID wird intern verwendet und sollte kurz und eindeutig sein.
</p>
</div>
<div>
<label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1">
Anzeigename
</label>
<input
type="text"
id="newOptionLabel"
value={newOptionLabel}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 mt-1">
Der Anzeigename wird den Abstimmenden angezeigt.
</p>
</div>
<button
onClick={handleAddVoteOption}
disabled={isLoading}
className="ssvc-button w-full"
>
{isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'}
</button>
</div>
<div className="mt-4 text-sm text-gray-600">
<p className="font-medium">Hinweis:</p>
<p>Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.</p>
</div>
</div>
)}
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#0057a6]">Mitgliederanmeldung</h2>
<button
onClick={handleToggleMemberAuth}
disabled={isTogglingMemberAuth}
className={`px-3 py-1 rounded text-white ${memberAuthEnabled
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 hover:bg-gray-700'
}`}
>
{isTogglingMemberAuth
? 'Wird geändert...'
: memberAuthEnabled
? 'Aktiviert'
: 'Deaktiviert'}
</button>
</div>
<div className="p-4 bg-[#e6f0fa] mb-4">
<p className="mb-3">
Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowMembers(!showMembers)}
className="ssvc-button flex-1"
>
{showMembers ? 'Mitgliederverwaltung ausblenden' : 'Mitgliederverwaltung anzeigen'}
</button>
</div>
</div>
{showMembers && (
<div className="mb-6">
<MembersManager />
</div>
)}
</div>
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungslinks</h2>
<div className="flex gap-2 mb-4">
<button
onClick={handleGenerateToken}
disabled={isLoading}
className="ssvc-button flex-1 disabled:opacity-50"
>
{isLoading ? 'Generiere...' : 'Abstimmungslink generieren'}
</button>
</div>
<div className="flex gap-2 mb-4">
{generatedLink && (
<div className="mt-6 p-4 bg-[#e6f0fa]">
<h3 className="font-medium text-[#0057a6] mb-2">Generierter Abstimmungslink:</h3>
<div className="break-all text-sm text-[#0057a6] mb-2">
{generatedLink}
</div>
<button
onClick={copyToClipboard}
className="w-full ssvc-button mt-2"
>
In die Zwischenablage kopieren
</button>
</div>
)}
</div>
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
<div className="mt-6 p-4">
<h3 className="font-medium text-[#0057a6] mb-2">Mehrere Abstimmungslinks generieren:</h3>
<div className="flex items-center gap-2 mb-3">
<label htmlFor="bulkTokenCount" className="text-sm">Anzahl der Links:</label>
<input
type="number"
id="bulkTokenCount"
min="1"
max="1000"
value={bulkTokenCount}
onChange={(e) => 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]"
/>
</div>
<button
onClick={handleGenerateBulkTokens}
disabled={isGeneratingBulk}
className="w-full ssvc-button"
>
{isGeneratingBulk ? 'Generiere CSV...' : 'Links als CSV generieren'}
</button>
<p className="text-xs text-gray-600 mt-2">
Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können.
</p>
</div>
</div>
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Zur&uuml;cksetzen</h2>
<div className="mb-4">
<button
onClick={handleResetVotes}
disabled={isResetting || isLoading}
className="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 transition-colors duration-300 disabled:opacity-50"
>
{isResetting ? 'Zurücksetzen...' : 'Abstimmungsdaten zurücksetzen'}
</button>
<p className="text-xs text-gray-600 mt-1">
Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden.
</p>
</div>
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
</div>
</div>
) : (
<div className="ssvc-main-content">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungsstatistiken</h2>
{stats && voteOptions.length > 0 && (
<div className="space-y-4">
<div className={`grid ${voteOptions.length === 1 ? 'grid-cols-1' : voteOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
{voteOptions.map((option) => (
<div key={option.id} className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{stats[option.id] || 0}</div>
<div className="text-sm text-[#0057a6]">{option.label}</div>
</div>
))}
</div>
<div className="bg-[#0057a6] p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-white">Gesamtstimmen</div>
</div>
{stats.total > 0 && (
<div className="mt-4">
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3>
<div className="h-6 bg-gray-200 overflow-hidden">
<div className="flex h-full">
{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 (
<div
key={option.id}
className={`${color} h-full`}
style={{ width: `${percentage}%` }}
></div>
);
})}
</div>
</div>
<div className="flex justify-between text-xs mt-1">
{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 (
<span key={option.id} className={textColor}>
{percentage}% {option.label}
</span>
);
})}
</div>
</div>
)}
</div>
)}
{comments.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-medium text-[#0057a6] mb-4">Kommentare der Teilnehmer</h3>
<div className="space-y-4">
{comments.map((comment, index) => (
<div key={index} className="p-4 bg-[#e6f0fa] rounded">
<div className="flex justify-between items-center mb-2">
<span className="font-medium">
{voteOptions.find(option => option.id === comment.vote)?.label || comment.vote}
</span>
<span className="text-xs text-gray-500">
{new Date(comment.timestamp).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-gray-700">{comment.comment}</p>
</div>
))}
</div>
</div>
)}
<button
onClick={() => setShowStats(false)}
className="ssvc-button mt-4"
>
Zurück zum Link-Generator
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -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: '<p>Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.</p><p>Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.</p>'
},
{
id: 'current-vote-text',
content: '<p>Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.</p><p>Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.</p><p>Bei Fragen wenden Sie sich bitte an den Vorstand.</p>'
},
{
id: 'vote-question',
content: '<p>Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?</p>'
},
{
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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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;
}

View File

@ -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 (
<html lang="en">
<html lang="de">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<header className="ssvc-header">
<div className="ssvc-logo-container">
<div className="w-24 h-24 relative">
<div className="absolute inset-0 bg-[#0057a6] flex items-center justify-center text-white font-bold" >
<Image
src="/ssvc-logo.svg"
alt="SSVC Logo"
width={92}
height={48}
priority
/>
</div>
</div>
<div className="ssvc-logo-text">
<div className="text-sm font-bold">SCHAFWASCHENER SEGELVEREIN</div>
<div className="text-sm font-bold">CHIEMSEE E.V.</div>
<div className="text-sm font-bold">RIMSTING</div>
</div>
</div>
</header>
<nav className="ssvc-nav">
<Link href="/">HOME</Link>
<Link href="/admin">ADMIN</Link>
</nav>
<main>
{children}
</main>
<footer className="ssvc-footer">
<p>© {new Date().getFullYear()} SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING. Alle Rechte vorbehalten.</p>
</footer>
</body>
</html>
);

158
src/app/login/page.tsx Normal file
View File

@ -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<string | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">MITGLIEDERANMELDUNG</h1>
<div className="ssvc-main-content">
<div className="text-center p-6">
<div className="mb-4 text-red-600">
Die Mitgliederanmeldung ist derzeit deaktiviert.
</div>
<Link href="/" className="ssvc-button inline-block">
Zurück zur Startseite
</Link>
</div>
</div>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">MITGLIEDERANMELDUNG</h1>
<div className="ssvc-main-content">
<div className="mb-6">
<EditableText id="current-vote-text" />
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="memberNumber" className="block text-sm font-medium text-gray-700 mb-1">
Mitgliedsnummer
</label>
<input
type="text"
id="memberNumber"
value={memberNumber}
onChange={(e) => setMemberNumber(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/" className="text-[#0057a6] hover:underline">
Zurück zur Startseite
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">WILLKOMMEN BEI DER ONLINE-ABSTIMMUNG</h1>
<div className="ssvc-main-content">
<EditableText id="welcome-text" />
<div className="mt-8">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">ZUGANG ZUR ABSTIMMUNG</h2>
<p className="mb-4">
Wenn Sie einen Abstimmungslink erhalten haben, verwenden Sie diesen bitte direkt, um auf Ihren Stimmzettel zuzugreifen.
</p>
{memberAuthEnabled && (
<div className="mb-4 p-4 bg-[#e6f0fa]">
<p className="mb-2">
Als Mitglied können Sie sich auch mit Ihrer Mitgliedsnummer und Ihrem Passwort anmelden.
</p>
<Link
href="/login"
className="ssvc-button inline-block text-center w-full"
>
ZUR MITGLIEDERANMELDUNG
</Link>
</div>
)}
<div className="flex flex-col gap-4 mt-6">
<Link
href="/abstimmung"
className="ssvc-button inline-block text-center mb-3"
>
ABSTIMMUNGSERGEBNISSE
</Link>
</div>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-[#0057a6] mb-4">AKTUELLE ABSTIMMUNG</h2>
<div className="ssvc-main-content">
<EditableText id="current-vote-text" />
</div>
</div>
</div>
);
}

189
src/app/vote/page.tsx Normal file
View File

@ -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<VoteOptionConfig[]>([]);
const [selectedOption, setSelectedOption] = useState<VoteOption | null>(null);
const [comment, setComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="ssvc-main-content text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-[#e6f0fa] flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-[#0057a6]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-[#0057a6] mb-2">Vielen Dank!</h2>
<p className="text-gray-600">
Ihre Stimme wurde erfolgreich übermittelt.
</p>
</div>
<Link
href="/"
className="ssvc-button inline-block"
>
Zurück zur Startseite
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">SATZUNGSÄNDERUNG - ABSTIMMUNG</h1>
<div className="ssvc-main-content">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<div className="text-xl font-bold text-[#0057a6] mb-4">
<EditableText id="vote-question" />
</div>
<div className="space-y-3">
{voteOptions.map((option) => (
<label
key={option.id}
className="flex items-center p-3 border border-gray-200 cursor-pointer hover:bg-[#e6f0fa] transition-colors"
>
<input
type="radio"
name="vote"
value={option.id}
checked={selectedOption === option.id}
onChange={() => setSelectedOption(option.id)}
className="h-5 w-5 text-[#0057a6] border-gray-300"
/>
<span className="ml-3 text-gray-800">{option.label}</span>
</label>
))}
</div>
</div>
<div>
<label htmlFor="comment" className="block text-sm font-medium text-gray-700 mb-1">
Kommentare oder Vorschläge (Optional)
</label>
<div className="mt-1">
<textarea
id="comment"
name="comment"
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Bitte geben Sie keine persönlichen Daten an"
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
/>
</div>
<p className="mt-1 text-sm text-gray-500">
Bitte geben Sie keine persönlichen Daten in Ihren Kommentaren an.
</p>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Wird übermittelt...' : 'Stimme abgeben'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -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<string>('');
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 <div className="animate-pulse bg-gray-200 h-20 w-full"></div>;
}
return (
<div
className="editable-text"
dangerouslySetInnerHTML={{ __html: content }}
/>
);
}

View File

@ -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<Member[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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<string | null>(null);
const [editPassword, setEditPassword] = useState('');
const fileInputRef = useRef<HTMLInputElement>(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 (
<div>
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Mitgliederverwaltung</h2>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{totalMembers}</div>
<div className="text-sm text-[#0057a6]">Gesamt</div>
</div>
<div className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{votedMembers}</div>
<div className="text-sm text-[#0057a6]">Abgestimmt</div>
</div>
<div className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{notVotedMembers}</div>
<div className="text-sm text-[#0057a6]">Nicht abgestimmt</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => {
setShowAddForm(!showAddForm);
setShowUploadForm(false);
setError(null);
setSuccess(null);
}}
className="ssvc-button"
>
{showAddForm ? 'Abbrechen' : 'Mitglied hinzufügen'}
</button>
<button
onClick={() => {
setShowUploadForm(!showUploadForm);
setShowAddForm(false);
setError(null);
setSuccess(null);
}}
className="ssvc-button"
>
{showUploadForm ? 'Abbrechen' : 'CSV importieren'}
</button>
</div>
{/* Add Member Form */}
{showAddForm && (
<div className="bg-[#e6f0fa] p-4 mb-6">
<h3 className="font-medium text-[#0057a6] mb-3">Neues Mitglied hinzufügen</h3>
<form onSubmit={handleAddMember} className="space-y-3">
<div>
<label htmlFor="newMemberNumber" className="block text-sm font-medium text-gray-700 mb-1">
Mitgliedsnummer
</label>
<input
type="text"
id="newMemberNumber"
value={newMemberNumber}
onChange={(e) => setNewMemberNumber(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<input
type="text"
id="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full"
>
{isLoading ? 'Wird hinzugefügt...' : 'Mitglied hinzufügen'}
</button>
</form>
</div>
)}
{/* Upload CSV Form */}
{showUploadForm && (
<div className="bg-[#e6f0fa] p-4 mb-6">
<h3 className="font-medium text-[#0057a6] mb-3">CSV-Datei importieren</h3>
<form onSubmit={handleUploadCSV} className="space-y-3">
<div>
<label htmlFor="csvFile" className="block text-sm font-medium text-gray-700 mb-1">
CSV-Datei auswählen
</label>
<input
type="file"
id="csvFile"
ref={fileInputRef}
accept=".csv"
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
<p className="text-xs text-gray-500 mt-1">
Die CSV-Datei sollte zwei Spalten enthalten: Mitgliedsnummer und Passwort.
</p>
</div>
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full"
>
{isLoading ? 'Wird hochgeladen...' : 'CSV importieren'}
</button>
</form>
</div>
)}
{/* Messages */}
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
{success && (
<div className="text-green-500 text-sm mb-4">{success}</div>
)}
{/* Search */}
<div className="mb-4">
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
Mitglieder suchen
</label>
<input
type="text"
id="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Mitgliedsnummer eingeben..."
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
/>
</div>
{/* Members List */}
{isLoading && members.length === 0 ? (
<div className="text-center p-4">Lade Mitglieder...</div>
) : filteredMembers.length === 0 ? (
<div className="text-center p-4">
{searchTerm ? 'Keine Mitglieder gefunden' : 'Keine Mitglieder vorhanden'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#0057a6] text-white">
<th className="p-2 text-left">Mitgliedsnummer</th>
<th className="p-2 text-center">Status</th>
<th className="p-2 text-center">Letzte Anmeldung</th>
<th className="p-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody>
{filteredMembers.map((member) => (
<tr key={member.memberNumber} className="border-b border-gray-200 hover:bg-gray-50">
<td className="p-2">{member.memberNumber}</td>
<td className="p-2 text-center">
<span
className={`inline-block px-2 py-1 rounded text-xs ${
member.hasVoted
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{member.hasVoted ? 'Abgestimmt' : 'Nicht abgestimmt'}
</span>
</td>
<td className="p-2 text-center text-sm text-gray-600">
{member.lastLogin
? new Date(member.lastLogin).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: '-'}
</td>
<td className="p-2 text-right">
{editingMember === member.memberNumber ? (
<div className="flex items-center justify-end gap-2">
<input
type="text"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="Neues Passwort"
className="px-2 py-1 border border-gray-300 text-sm w-32"
/>
<button
onClick={() => handleUpdateMember(member.memberNumber)}
className="text-[#0057a6] hover:underline text-sm"
>
Speichern
</button>
<button
onClick={() => {
setEditingMember(null);
setEditPassword('');
}}
className="text-gray-500 hover:underline text-sm"
>
Abbrechen
</button>
</div>
) : (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
setEditingMember(member.memberNumber);
setEditPassword('');
setError(null);
setSuccess(null);
}}
className="text-[#0057a6] hover:underline text-sm"
title="Passwort ändern"
>
Bearbeiten
</button>
<button
onClick={() => handleToggleVotingStatus(member.memberNumber, member.hasVoted)}
className="text-orange-600 hover:underline text-sm"
title={member.hasVoted ? 'Als nicht abgestimmt markieren' : 'Als abgestimmt markieren'}
>
Status ändern
</button>
<button
onClick={() => handleDeleteMember(member.memberNumber)}
className="text-red-600 hover:underline text-sm"
title="Mitglied löschen"
>
Löschen
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -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: () => <div className="border border-gray-300 h-64 flex items-center justify-center">Loading editor...</div>
});
// 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 <div className="border border-gray-300 h-64 flex items-center justify-center">Loading editor...</div>;
}
return (
<div className="quill-wrapper">
<ReactQuill
value={value}
onChange={onChange}
modules={modules}
placeholder={placeholder}
theme="snow"
/>
</div>
);
};
export default QuillEditor;

103
src/lib/auth.ts Normal file
View File

@ -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<string> {
// 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<string> {
// 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<boolean> {
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<boolean> {
// 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);
}

321
src/lib/server-auth.ts Normal file
View File

@ -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

128
src/lib/survey.ts Normal file
View File

@ -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<string, number> = {
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;
}

8
src/middleware.ts Normal file
View File

@ -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();
}

13
tailwind.config.js Normal file
View File

@ -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: [],
}