initial version
This commit is contained in:
parent
c0f3deeb27
commit
86d26a1e41
34
.dockerignore
Normal file
34
.dockerignore
Normal 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
55
Dockerfile
Normal 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
134
README.md
@ -1,36 +1,126 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# SCHAFWASCHENER SEGELVEREIN Voting System
|
||||
|
||||
A secure web application for conducting online votes for SCHAFWASCHENER SEGELVEREIN club members.
|
||||
|
||||
## Features
|
||||
|
||||
- **Secure One-Time Voting Links**: Admin can generate unique, one-time use voting links that are cryptographically signed.
|
||||
- **Simple Voting Interface**: Members can easily vote Yes, No, or Abstain on proposed statute amendments.
|
||||
- **Optional Comments**: Members can provide anonymous feedback or suggestions.
|
||||
- **Results Dashboard**: Admin can view real-time voting statistics.
|
||||
- **Dark Mode Support**: Automatically adapts to user's system preferences.
|
||||
- **WYSIWYG Text Editor**: Admin can edit text content using a rich text editor.
|
||||
- **JWT Authentication**: Admin authentication using JWT tokens stored in cookies.
|
||||
- **Secure Key Management**: JWT keys are randomly generated and stored securely.
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
- Built with Next.js 15 and React 19
|
||||
- Styled with Tailwind CSS
|
||||
- JWT-like token system for secure, one-time voting links
|
||||
- File-based storage for vote data
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
- Node.js 18+ and npm
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### Installation
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
3. Run the development server:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
4. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
### Admin Access
|
||||
|
||||
## Learn More
|
||||
The default admin password is `schafwaschener-segelverein-admin`. This can be changed by setting the `ADMIN_PASSWORD` environment variable.
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Once logged in, the admin session is maintained using JWT tokens, so you don't need to enter the password for each action.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Usage
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
### Admin
|
||||
|
||||
## Deploy on Vercel
|
||||
1. Navigate to `/admin` and enter the admin password
|
||||
2. Generate voting links and share them with club members
|
||||
3. View voting statistics in real-time
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
### Voters
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
1. Click on the unique voting link received
|
||||
2. Select your vote (Yes, No, or Abstain)
|
||||
3. Optionally add comments or suggestions
|
||||
4. Submit your vote
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ADMIN_PASSWORD`: Password for admin access (default: "schafwaschener-segelverein-admin")
|
||||
- `NEXT_PUBLIC_BASE_URL`: Base URL for generating voting links (default: auto-detected)
|
||||
- `JWT_SECRET_KEY`: Secret key for JWT signing (default: randomly generated)
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Standard Deployment
|
||||
|
||||
For production deployment, consider the following:
|
||||
|
||||
1. Set a secure admin password using the `ADMIN_PASSWORD` environment variable
|
||||
2. Set a secure JWT secret key using the `JWT_SECRET_KEY` environment variable
|
||||
3. Use a database instead of file-based storage
|
||||
4. Set up HTTPS for secure communication
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
The application can be easily deployed using Docker:
|
||||
|
||||
#### Using Docker Compose (Recommended)
|
||||
|
||||
1. Make sure you have Docker and Docker Compose installed
|
||||
2. Run the application:
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
Or with a custom admin password:
|
||||
```
|
||||
ADMIN_PASSWORD=your-secure-password docker-compose up -d
|
||||
```
|
||||
3. Access the application at [http://localhost:3000](http://localhost:3000)
|
||||
4. To stop the application:
|
||||
```
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
#### Using Docker Directly
|
||||
|
||||
1. Build the Docker image:
|
||||
```
|
||||
./build-docker.sh
|
||||
```
|
||||
or manually:
|
||||
```
|
||||
docker build -t ssvc-rimsting-vote:latest .
|
||||
```
|
||||
|
||||
2. Run the container:
|
||||
```
|
||||
docker run -p 3000:3000 ssvc-rimsting-vote:latest
|
||||
```
|
||||
|
||||
3. For persistent data storage and custom admin password:
|
||||
```
|
||||
docker run -p 3000:3000 -v $(pwd)/data:/app/data -e ADMIN_PASSWORD=your-secure-password ssvc-rimsting-vote:latest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is proprietary and confidential. Unauthorized copying, distribution, or use is strictly prohibited.
|
||||
|
||||
© SCHAFWASCHENER SEGELVEREIN
|
||||
|
26
build-docker.sh
Executable file
26
build-docker.sh
Executable 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
35
data/editable_text.json
Normal 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
4
data/jwt_keys.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"privateKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264",
|
||||
"publicKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264"
|
||||
}
|
13
data/member_credentials.json
Normal file
13
data/member_credentials.json
Normal 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
1
data/responses.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
3
data/settings.json
Normal file
3
data/settings.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"memberAuthEnabled": true
|
||||
}
|
1
data/used_tokens.json
Normal file
1
data/used_tokens.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal 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
131
package-lock.json
generated
@ -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",
|
||||
|
15
package.json
15
package.json
@ -9,19 +9,24 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"jose": "^6.0.8",
|
||||
"next": "15.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.2.0"
|
||||
"react-quill-new": "^3.3.3",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
2
public/ssvc-logo.svg
Normal file
2
public/ssvc-logo.svg
Normal 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
42
run-docker.sh
Executable 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
168
src/app/abstimmung/page.tsx
Normal 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
948
src/app/admin/page.tsx
Normal 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ü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>
|
||||
);
|
||||
}
|
131
src/app/api/editable-text/route.ts
Normal file
131
src/app/api/editable-text/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
57
src/app/api/generate-bulk-tokens/route.ts
Normal file
57
src/app/api/generate-bulk-tokens/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
35
src/app/api/generate-token/route.ts
Normal file
35
src/app/api/generate-token/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
70
src/app/api/member-login/route.ts
Normal file
70
src/app/api/member-login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
150
src/app/api/members/route.ts
Normal file
150
src/app/api/members/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
17
src/app/api/public-stats/route.ts
Normal file
17
src/app/api/public-stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
44
src/app/api/reset-votes/route.ts
Normal file
44
src/app/api/reset-votes/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
17
src/app/api/settings/route.ts
Normal file
17
src/app/api/settings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
41
src/app/api/stats/route.ts
Normal file
41
src/app/api/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
49
src/app/api/submit-vote/route.ts
Normal file
49
src/app/api/submit-vote/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
43
src/app/api/toggle-member-auth/route.ts
Normal file
43
src/app/api/toggle-member-auth/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
55
src/app/api/upload-members/route.ts
Normal file
55
src/app/api/upload-members/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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
158
src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
160
src/app/page.tsx
160
src/app/page.tsx
@ -1,101 +1,73 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { EditableText } from '@/components/EditableText';
|
||||
|
||||
export default function Home() {
|
||||
const [memberAuthEnabled, setMemberAuthEnabled] = useState(false);
|
||||
|
||||
// Fetch settings on component mount
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMemberAuthEnabled(data.settings.memberAuthEnabled);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, []);
|
||||
return (
|
||||
<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
189
src/app/vote/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
53
src/components/EditableText.tsx
Normal file
53
src/components/EditableText.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
537
src/components/MembersManager.tsx
Normal file
537
src/components/MembersManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
src/components/QuillEditor.tsx
Normal file
59
src/components/QuillEditor.tsx
Normal 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
103
src/lib/auth.ts
Normal 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
321
src/lib/server-auth.ts
Normal 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
128
src/lib/survey.ts
Normal 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
8
src/middleware.ts
Normal 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
13
tailwind.config.js
Normal 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: [],
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user