From 86d26a1e418cea6109619bf3545f6b3fc4e66a05 Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Sun, 2 Mar 2025 17:12:22 +0000 Subject: [PATCH] initial version --- .dockerignore | 34 + Dockerfile | 55 ++ README.md | 134 ++- build-docker.sh | 26 + data/editable_text.json | 35 + data/jwt_keys.json | 4 + data/member_credentials.json | 13 + data/responses.json | 1 + data/settings.json | 3 + data/used_tokens.json | 1 + docker-compose.yml | 18 + package-lock.json | 131 ++- package.json | 15 +- public/ssvc-logo.svg | 2 + run-docker.sh | 42 + src/app/abstimmung/page.tsx | 168 ++++ src/app/admin/page.tsx | 948 ++++++++++++++++++++++ src/app/api/editable-text/route.ts | 131 +++ src/app/api/generate-bulk-tokens/route.ts | 57 ++ src/app/api/generate-token/route.ts | 35 + src/app/api/member-login/route.ts | 70 ++ src/app/api/members/route.ts | 150 ++++ src/app/api/public-stats/route.ts | 17 + src/app/api/reset-votes/route.ts | 44 + src/app/api/settings/route.ts | 17 + src/app/api/stats/route.ts | 41 + src/app/api/submit-vote/route.ts | 49 ++ src/app/api/toggle-member-auth/route.ts | 43 + src/app/api/upload-members/route.ts | 55 ++ src/app/globals.css | 76 +- src/app/layout.tsx | 39 +- src/app/login/page.tsx | 158 ++++ src/app/page.tsx | 160 ++-- src/app/vote/page.tsx | 189 +++++ src/components/EditableText.tsx | 53 ++ src/components/MembersManager.tsx | 537 ++++++++++++ src/components/QuillEditor.tsx | 59 ++ src/lib/auth.ts | 103 +++ src/lib/server-auth.ts | 321 ++++++++ src/lib/survey.ts | 128 +++ src/middleware.ts | 8 + tailwind.config.js | 13 + 42 files changed, 4051 insertions(+), 132 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 build-docker.sh create mode 100644 data/editable_text.json create mode 100644 data/jwt_keys.json create mode 100644 data/member_credentials.json create mode 100644 data/responses.json create mode 100644 data/settings.json create mode 100644 data/used_tokens.json create mode 100644 docker-compose.yml create mode 100644 public/ssvc-logo.svg create mode 100755 run-docker.sh create mode 100644 src/app/abstimmung/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/editable-text/route.ts create mode 100644 src/app/api/generate-bulk-tokens/route.ts create mode 100644 src/app/api/generate-token/route.ts create mode 100644 src/app/api/member-login/route.ts create mode 100644 src/app/api/members/route.ts create mode 100644 src/app/api/public-stats/route.ts create mode 100644 src/app/api/reset-votes/route.ts create mode 100644 src/app/api/settings/route.ts create mode 100644 src/app/api/stats/route.ts create mode 100644 src/app/api/submit-vote/route.ts create mode 100644 src/app/api/toggle-member-auth/route.ts create mode 100644 src/app/api/upload-members/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/vote/page.tsx create mode 100644 src/components/EditableText.tsx create mode 100644 src/components/MembersManager.tsx create mode 100644 src/components/QuillEditor.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/server-auth.ts create mode 100644 src/lib/survey.ts create mode 100644 src/middleware.ts create mode 100644 tailwind.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa26da7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Next.js build output +.next +out + +# Git +.git +.gitignore + +# Docker +Dockerfile +.dockerignore + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE files +.idea +.vscode +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a17447 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Stage 1: Build stage +FROM node:18-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy all files +COPY . . + +# Build the application +RUN npm run build + +# Stage 2: Production stage +FROM node:18-alpine AS runner + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV NODE_ENV=production + +# Create app directory +RUN mkdir -p /app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +RUN chown -R nextjs:nodejs /app + +# Copy necessary files from builder stage +COPY --from=builder --chown=nextjs:nodejs /app/package*.json ./ +COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./ +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next +COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules + +# Create data directory and ensure it's writable +RUN mkdir -p /app/data +COPY --from=builder --chown=nextjs:nodejs /app/data ./data +RUN chown -R nextjs:nodejs /app/data + +# Switch to non-root user +USER nextjs + +# Expose the port the app will run on +EXPOSE 3000 + +# Command to run the application +CMD ["npm", "start"] diff --git a/README.md b/README.md index e215bc4..7c27df6 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,126 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# SCHAFWASCHENER SEGELVEREIN Voting System + +A secure web application for conducting online votes for SCHAFWASCHENER SEGELVEREIN club members. + +## Features + +- **Secure One-Time Voting Links**: Admin can generate unique, one-time use voting links that are cryptographically signed. +- **Simple Voting Interface**: Members can easily vote Yes, No, or Abstain on proposed statute amendments. +- **Optional Comments**: Members can provide anonymous feedback or suggestions. +- **Results Dashboard**: Admin can view real-time voting statistics. +- **Dark Mode Support**: Automatically adapts to user's system preferences. +- **WYSIWYG Text Editor**: Admin can edit text content using a rich text editor. +- **JWT Authentication**: Admin authentication using JWT tokens stored in cookies. +- **Secure Key Management**: JWT keys are randomly generated and stored securely. + +## Technical Implementation + +- Built with Next.js 15 and React 19 +- Styled with Tailwind CSS +- JWT-like token system for secure, one-time voting links +- File-based storage for vote data ## Getting Started -First, run the development server: +### Prerequisites -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +- Node.js 18+ and npm -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### Installation -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +1. Clone the repository +2. Install dependencies: + ``` + npm install + ``` +3. Run the development server: + ``` + npm run dev + ``` +4. Open [http://localhost:3000](http://localhost:3000) in your browser -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +### Admin Access -## Learn More +The default admin password is `schafwaschener-segelverein-admin`. This can be changed by setting the `ADMIN_PASSWORD` environment variable. -To learn more about Next.js, take a look at the following resources: +Once logged in, the admin session is maintained using JWT tokens, so you don't need to enter the password for each action. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Usage -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +### Admin -## Deploy on Vercel +1. Navigate to `/admin` and enter the admin password +2. Generate voting links and share them with club members +3. View voting statistics in real-time -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +### Voters -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +1. Click on the unique voting link received +2. Select your vote (Yes, No, or Abstain) +3. Optionally add comments or suggestions +4. Submit your vote + +## Environment Variables + +- `ADMIN_PASSWORD`: Password for admin access (default: "schafwaschener-segelverein-admin") +- `NEXT_PUBLIC_BASE_URL`: Base URL for generating voting links (default: auto-detected) +- `JWT_SECRET_KEY`: Secret key for JWT signing (default: randomly generated) + +## Production Deployment + +### Standard Deployment + +For production deployment, consider the following: + +1. Set a secure admin password using the `ADMIN_PASSWORD` environment variable +2. Set a secure JWT secret key using the `JWT_SECRET_KEY` environment variable +3. Use a database instead of file-based storage +4. Set up HTTPS for secure communication + +### Docker Deployment + +The application can be easily deployed using Docker: + +#### Using Docker Compose (Recommended) + +1. Make sure you have Docker and Docker Compose installed +2. Run the application: + ``` + docker-compose up -d + ``` + Or with a custom admin password: + ``` + ADMIN_PASSWORD=your-secure-password docker-compose up -d + ``` +3. Access the application at [http://localhost:3000](http://localhost:3000) +4. To stop the application: + ``` + docker-compose down + ``` + +#### Using Docker Directly + +1. Build the Docker image: + ``` + ./build-docker.sh + ``` + or manually: + ``` + docker build -t ssvc-rimsting-vote:latest . + ``` + +2. Run the container: + ``` + docker run -p 3000:3000 ssvc-rimsting-vote:latest + ``` + +3. For persistent data storage and custom admin password: + ``` + docker run -p 3000:3000 -v $(pwd)/data:/app/data -e ADMIN_PASSWORD=your-secure-password ssvc-rimsting-vote:latest + ``` + +## License + +This project is proprietary and confidential. Unauthorized copying, distribution, or use is strictly prohibited. + +© SCHAFWASCHENER SEGELVEREIN diff --git a/build-docker.sh b/build-docker.sh new file mode 100755 index 0000000..6daf2f1 --- /dev/null +++ b/build-docker.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Set image name and tag +IMAGE_NAME="ssvc-rimsting-vote" +TAG="latest" + +# Build the Docker image +echo "Building Docker image: ${IMAGE_NAME}:${TAG}" +docker build -t ${IMAGE_NAME}:${TAG} . + +# Check if build was successful +if [ $? -eq 0 ]; then + echo "Docker image built successfully!" + echo "" + echo "Image details:" + docker images ${IMAGE_NAME}:${TAG} + echo "" + echo "To run the container:" + echo "docker run -p 3000:3000 ${IMAGE_NAME}:${TAG}" + echo "" + echo "To run the container with persistent data volume:" + echo "docker run -p 3000:3000 -v $(pwd)/data:/app/data ${IMAGE_NAME}:${TAG}" +else + echo "Error building Docker image" + exit 1 +fi diff --git a/data/editable_text.json b/data/editable_text.json new file mode 100644 index 0000000..4c10b2d --- /dev/null +++ b/data/editable_text.json @@ -0,0 +1,35 @@ +[ + { + "id": "welcome-text", + "content": "

Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.

Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.

" + }, + { + "id": "current-vote-text", + "content": "

Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.

Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.

Bei Fragen wenden Sie sich bitte an den Vorstand.

" + }, + { + "id": "vote-question", + "content": "

Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?

" + }, + { + "id": "vote-options", + "content": [ + { + "id": "yes", + "label": "Ja, ich stimme zu" + }, + { + "id": "no", + "label": "Nein, ich stimme nicht zu" + }, + { + "id": "abstain", + "label": "Ich enthalte mich" + }, + { + "id": "nichts", + "label": "Wild" + } + ] + } +] \ No newline at end of file diff --git a/data/jwt_keys.json b/data/jwt_keys.json new file mode 100644 index 0000000..bcd9d54 --- /dev/null +++ b/data/jwt_keys.json @@ -0,0 +1,4 @@ +{ + "privateKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264", + "publicKey": "5524d00e7311dedf4c879c2591c7a6fdad58c57f096519f92a66a7aa45c11264" +} \ No newline at end of file diff --git a/data/member_credentials.json b/data/member_credentials.json new file mode 100644 index 0000000..90c5ec5 --- /dev/null +++ b/data/member_credentials.json @@ -0,0 +1,13 @@ +[ + { + "memberNumber": "test", + "password": "$2b$10$.Q68xrauRpHLKVCKQP6veeFE3NbgX.uv9gIrHDvbgztWU8K/d2pgG", + "hasVoted": false + }, + { + "memberNumber": "123", + "password": "$2b$10$Lu9T/9qWftBU//G2n.URf.xvQupJpIm7nOG2R0kbOS5TOs0TELrcy", + "hasVoted": false, + "lastLogin": "2025-03-02T15:59:48.419Z" + } +] \ No newline at end of file diff --git a/data/responses.json b/data/responses.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/responses.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 0000000..c6418b2 --- /dev/null +++ b/data/settings.json @@ -0,0 +1,3 @@ +{ + "memberAuthEnabled": true +} \ No newline at end of file diff --git a/data/used_tokens.json b/data/used_tokens.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/used_tokens.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ae9322 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3' + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: ssvc-rimsting-vote:latest + container_name: ssvc-rimsting-vote + ports: + - "3000:3000" + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-schafwaschener-segelverein-admin} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-} diff --git a/package-lock.json b/package-lock.json index 5d59720..d42264a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,14 @@ "name": "ssvc-rimsting-vote", "version": "0.1.0", "dependencies": { + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.2", + "jose": "^6.0.8", "next": "15.2.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-quill-new": "^3.3.3", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1060,6 +1065,12 @@ "tailwindcss": "4.0.9" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1637,6 +1648,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2645,6 +2665,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2652,6 +2678,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -3550,6 +3582,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.8.tgz", + "integrity": "sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3919,6 +3960,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4300,6 +4366,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4451,6 +4523,35 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", @@ -4479,6 +4580,21 @@ "dev": true, "license": "MIT" }, + "node_modules/react-quill-new": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.3.3.tgz", + "integrity": "sha512-jxbm1QUJlkuGUpc9/GUgGw5USLHdp43H0M7AufqS3V+zRLng9uqLeVBGjXYqEbUKi8QVOM4SClSV3F7kVNj68w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "quill": "~2.0.2" + }, + "peerDependencies": { + "quill-delta": "^5.1.0", + "react": "^16 || ^17 || ^18 || ^19", + "react-dom": "^16 || ^17 || ^18 || ^19" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5338,6 +5454,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2589435..ae9c048 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,24 @@ "lint": "next lint" }, "dependencies": { + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.2", + "jose": "^6.0.8", + "next": "15.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "next": "15.2.0" + "react-quill-new": "^3.3.3", + "uuid": "^11.1.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.2.0", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/public/ssvc-logo.svg b/public/ssvc-logo.svg new file mode 100644 index 0000000..b663dca --- /dev/null +++ b/public/ssvc-logo.svg @@ -0,0 +1,2 @@ + + diff --git a/run-docker.sh b/run-docker.sh new file mode 100755 index 0000000..9bd3c8d --- /dev/null +++ b/run-docker.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Set image name and tag +IMAGE_NAME="ssvc-rimsting-vote" +TAG="latest" + +# Check if the image exists +if [[ "$(docker images -q ${IMAGE_NAME}:${TAG} 2> /dev/null)" == "" ]]; then + echo "Image ${IMAGE_NAME}:${TAG} not found. Building it first..." + ./build-docker.sh + + # Exit if build failed + if [ $? -ne 0 ]; then + echo "Failed to build the image. Exiting." + exit 1 + fi +fi + +# Run the container +echo "Starting container from image ${IMAGE_NAME}:${TAG}..." +echo "The application will be available at http://localhost:3000" + +# Stop any existing container with the same name +CONTAINER_NAME="ssvc-rimsting-vote" +if [ "$(docker ps -aq -f name=${CONTAINER_NAME})" ]; then + echo "Stopping existing container..." + docker stop ${CONTAINER_NAME} > /dev/null + docker rm ${CONTAINER_NAME} > /dev/null +fi + +# Run the container with data volume mounted +docker run -d \ + --name ${CONTAINER_NAME} \ + -p 3000:3000 \ + -v "$(pwd)/data:/app/data" \ + -e "ADMIN_PASSWORD=${ADMIN_PASSWORD:-schafwaschener-segelverein-admin}" \ + --restart unless-stopped \ + ${IMAGE_NAME}:${TAG} + +echo "Container started successfully!" +echo "To view logs: docker logs ${CONTAINER_NAME}" +echo "To stop: docker stop ${CONTAINER_NAME}" diff --git a/src/app/abstimmung/page.tsx b/src/app/abstimmung/page.tsx new file mode 100644 index 0000000..e9b0719 --- /dev/null +++ b/src/app/abstimmung/page.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { VoteOptionConfig } from '@/lib/survey'; + +// Define a more flexible stats interface +interface Stats { + total: number; + [key: string]: number; // Allow any string keys for dynamic vote options +} + +export default function PublicResultsPage() { + const [stats, setStats] = useState(null); + const [voteOptions, setVoteOptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch vote options + useEffect(() => { + const fetchVoteOptions = async () => { + try { + const response = await fetch('/api/editable-text'); + if (response.ok) { + const data = await response.json(); + if (data.texts && Array.isArray(data.texts)) { + const voteOptionsEntry = data.texts.find((text: any) => text.id === 'vote-options'); + if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) { + setVoteOptions(voteOptionsEntry.content); + } + } + } + } catch (error) { + console.error('Error fetching vote options:', error); + // Use default options if fetch fails + setVoteOptions([ + { id: 'yes', label: 'Ja' }, + { id: 'no', label: 'Nein' }, + { id: 'abstain', label: 'Enthaltung' } + ]); + } + }; + + fetchVoteOptions(); + }, []); + + useEffect(() => { + const fetchStats = async () => { + try { + const response = await fetch('/api/public-stats'); + + if (!response.ok) { + throw new Error('Fehler beim Abrufen der Statistiken'); + } + + const data = await response.json(); + setStats(data.stats); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + fetchStats(); + }, []); + + if (isLoading) { + return ( +
+
+

Lade Ergebnisse...

+
+
+ ); + } + + if (error) { + return ( +
+
+
{error}
+ + Zurück zur Startseite + +
+
+ ); + } + + return ( +
+
+

ABSTIMMUNGSERGEBNISSE

+ +
+

Aktuelle Ergebnisse der Satzungsänderung

+ + {stats && voteOptions.length > 0 && ( +
+
+ {voteOptions.map((option) => ( +
+
{stats[option.id] || 0}
+
{option.label}
+
+ ))} +
+ +
+
{stats.total}
+
Gesamtstimmen
+
+ + {stats.total > 0 && ( +
+

Ergebnisübersicht

+
+
+ {voteOptions.map((option, index) => { + const percentage = ((stats[option.id] || 0) / stats.total) * 100; + // Define colors for different options + const colors = [ + 'bg-[#0057a6]', // Blue for first option (usually yes) + 'bg-red-500', // Red for second option (usually no) + 'bg-gray-500' // Gray for third option (usually abstain) + ]; + // Use a default color for additional options + const color = index < colors.length ? colors[index] : 'bg-green-500'; + + return ( +
+ ); + })} +
+
+
+ {voteOptions.map((option, index) => { + const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100); + // Define text colors for different options + const textColors = [ + 'text-[#0057a6]', // Blue for first option (usually yes) + 'text-red-600', // Red for second option (usually no) + 'text-gray-600' // Gray for third option (usually abstain) + ]; + // Use a default color for additional options + const textColor = index < textColors.length ? textColors[index] : 'text-green-600'; + + return ( + + {percentage}% {option.label} + + ); + })} +
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..ca9fb7c --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,948 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { VoteOption } from '@/lib/survey'; +import QuillEditor from '@/components/QuillEditor'; +import MembersManager from '@/components/MembersManager'; + +// Define types based on the data structure +interface Stats { + total: number; + [key: string]: number; // Allow dynamic keys for vote options +} + +interface Comment { + vote: VoteOption; + comment: string; + timestamp: string; +} + +interface EditableText { + id: string; + content: string; +} + +export default function AdminPage() { + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [generatedLink, setGeneratedLink] = useState(null); + const [bulkTokenCount, setBulkTokenCount] = useState(10); + const [isGeneratingBulk, setIsGeneratingBulk] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [showStats, setShowStats] = useState(false); + const [stats, setStats] = useState(null); + const [comments, setComments] = useState([]); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [showEditor, setShowEditor] = useState(false); + const [editableTexts, setEditableTexts] = useState([ + { id: 'welcome-text', content: '

Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.

Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.

' }, + { id: 'current-vote-text', content: '

Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.

Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.

Bei Fragen wenden Sie sich bitte an den Vorstand.

' }, + { id: 'vote-question', content: '

Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?

' } + ]); + const [voteOptions, setVoteOptions] = useState<{ id: string; label: string }[]>([ + { id: 'yes', label: 'Ja, ich stimme zu' }, + { id: 'no', label: 'Nein, ich stimme nicht zu' }, + { id: 'abstain', label: 'Ich enthalte mich' } + ]); + const [showVoteOptions, setShowVoteOptions] = useState(false); + const [newOptionId, setNewOptionId] = useState(''); + const [newOptionLabel, setNewOptionLabel] = useState(''); + const [selectedTextId, setSelectedTextId] = useState(null); + const [editorContent, setEditorContent] = useState(''); + const [showMembers, setShowMembers] = useState(false); + const [memberAuthEnabled, setMemberAuthEnabled] = useState(false); + const [isTogglingMemberAuth, setIsTogglingMemberAuth] = useState(false); + + // Check if already authenticated and load settings on component mount + useEffect(() => { + const checkAuthAndSettings = async () => { + try { + // Check authentication + const response = await fetch('/api/generate-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), // Empty body to check if cookie auth works + }); + + if (response.ok) { + setIsAuthenticated(true); + + // Get current settings + const settingsResponse = await fetch('/api/settings'); + if (settingsResponse.ok) { + const data = await settingsResponse.json(); + setMemberAuthEnabled(data.settings.memberAuthEnabled); + } + } + } catch (err) { + // Silently fail, user will need to enter password + } + }; + + checkAuthAndSettings(); + }, []); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/generate-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Anmeldung fehlgeschlagen'); + } + + setIsAuthenticated(true); + setPassword(''); // Clear password field after successful login + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + const handleGenerateToken = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/generate-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), // No need to send password, using JWT cookie + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Token konnte nicht generiert werden'); + } + + setGeneratedLink(data.voteUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + const handleGetStats = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/stats', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), // No need to send password, using JWT cookie + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Statistiken konnten nicht abgerufen werden'); + } + + setStats(data.stats); + setComments(data.comments || []); + setShowStats(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + const handleEditText = (textId: string) => { + const textToEdit = editableTexts.find(text => text.id === textId); + if (textToEdit) { + setSelectedTextId(textId); + setEditorContent(textToEdit.content); + setShowEditor(true); + } + }; + + // Load editable texts and vote options on component mount + useEffect(() => { + const loadEditableTexts = async () => { + try { + const response = await fetch('/api/editable-text'); + if (response.ok) { + const data = await response.json(); + if (data.texts && Array.isArray(data.texts)) { + setEditableTexts(data.texts); + + // Find and set vote options + const voteOptionsEntry = data.texts.find((text: any) => text.id === 'vote-options'); + if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) { + setVoteOptions(voteOptionsEntry.content); + } + } + } + } catch (err) { + console.error('Error loading editable texts:', err); + } + }; + + if (isAuthenticated) { + loadEditableTexts(); + } + }, [isAuthenticated]); + + const handleSaveText = async () => { + if (selectedTextId) { + setIsLoading(true); + + try { + // Update local state + setEditableTexts(prev => + prev.map(text => + text.id === selectedTextId + ? { ...text, content: editorContent } + : text + ) + ); + + // Save to server + const response = await fetch('/api/editable-text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: selectedTextId, + content: editorContent + }), + }); + + if (!response.ok) { + throw new Error('Failed to save text'); + } + + setShowEditor(false); + setSelectedTextId(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + } + }; + + // Add a new vote option + const handleAddVoteOption = async () => { + if (!newOptionId || !newOptionLabel) { + setError('Bitte geben Sie sowohl eine ID als auch ein Label für die neue Option ein'); + return; + } + + // Check if ID already exists + if (voteOptions.some(option => option.id === newOptionId)) { + setError('Eine Option mit dieser ID existiert bereits'); + return; + } + + setIsLoading(true); + setError(null); + + try { + // Add to local state + const updatedOptions = [...voteOptions, { id: newOptionId, label: newOptionLabel }]; + setVoteOptions(updatedOptions); + + // Save to server + const response = await fetch('/api/editable-text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 'vote-options', + content: updatedOptions + }), + }); + + if (!response.ok) { + throw new Error('Fehler beim Speichern der Abstimmungsoptionen'); + } + + // Reset form + setNewOptionId(''); + setNewOptionLabel(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + // Remove a vote option + const handleRemoveVoteOption = async (optionId: string) => { + // Don't allow removing if there are less than 2 options + if (voteOptions.length <= 1) { + setError('Es muss mindestens eine Abstimmungsoption vorhanden sein'); + return; + } + + // Show confirmation dialog + if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionId}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) { + return; + } + + setIsLoading(true); + setError(null); + + try { + // Remove from local state + const updatedOptions = voteOptions.filter(option => option.id !== optionId); + setVoteOptions(updatedOptions); + + // Save to server + const response = await fetch('/api/editable-text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 'vote-options', + content: updatedOptions + }), + }); + + if (!response.ok) { + throw new Error('Fehler beim Speichern der Abstimmungsoptionen'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + // Update a vote option + const handleUpdateVoteOption = async (optionId: string, newLabel: string) => { + if (!newLabel.trim()) { + setError('Das Label darf nicht leer sein'); + return; + } + + setIsLoading(true); + setError(null); + + try { + // Update in local state + const updatedOptions = voteOptions.map(option => + option.id === optionId ? { ...option, label: newLabel } : option + ); + setVoteOptions(updatedOptions); + + // Save to server + const response = await fetch('/api/editable-text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 'vote-options', + content: updatedOptions + }), + }); + + if (!response.ok) { + throw new Error('Fehler beim Speichern der Abstimmungsoptionen'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + const copyToClipboard = () => { + if (generatedLink) { + navigator.clipboard.writeText(generatedLink); + alert('Link in die Zwischenablage kopiert!'); + } + }; + + const handleToggleMemberAuth = async () => { + setIsTogglingMemberAuth(true); + setError(null); + + try { + const response = await fetch('/api/toggle-member-auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Fehler beim Ändern der Einstellung'); + } + + setMemberAuthEnabled(data.memberAuthEnabled); + + // Show success message + alert(data.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsTogglingMemberAuth(false); + } + }; + + const handleResetVotes = async () => { + // Show confirmation dialog + if (!confirm('Sind Sie sicher, dass Sie alle Abstimmungsdaten zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) { + return; + } + + setIsResetting(true); + setError(null); + + try { + const response = await fetch('/api/reset-votes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), // No need to send password, using JWT cookie + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Fehler beim Zurücksetzen der Abstimmungsdaten'); + } + + // Show success message + alert('Abstimmungsdaten wurden erfolgreich zurückgesetzt.'); + + // If stats are currently shown, refresh them + if (showStats) { + handleGetStats(); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsResetting(false); + } + }; + + const handleGenerateBulkTokens = async () => { + setIsGeneratingBulk(true); + setError(null); + + try { + // Generate the specified number of tokens + const response = await fetch('/api/generate-bulk-tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ count: bulkTokenCount }), + }); + + if (!response.ok) { + // Try to parse error as JSON + try { + const errorData = await response.json(); + throw new Error(errorData.error || 'Fehler beim Generieren der Tokens'); + } catch (jsonError) { + // If not JSON, use status text + throw new Error(`Fehler beim Generieren der Tokens: ${response.statusText}`); + } + } + + // For successful responses, get the CSV data and create a download + const csvData = await response.text(); + const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + + // Create a temporary link and trigger download + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `abstimmungslinks_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + + // Clean up + setTimeout(() => { + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, 100); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsGeneratingBulk(false); + } + }; + + // Login form if not authenticated + if (!isAuthenticated) { + return ( +
+
+

ADMIN-BEREICH

+ +
+
+
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + required + /> +
+ + {error && ( +
{error}
+ )} + + +
+
+
+
+ ); + } + + // WYSIWYG editor view + if (showEditor && selectedTextId) { + return ( +
+
+

TEXT BEARBEITEN

+ +
+
+

+ {selectedTextId === 'welcome-text' && 'Willkommenstext'} + {selectedTextId === 'current-vote-text' && 'Aktuelle Abstimmung Text'} + {selectedTextId === 'vote-question' && 'Abstimmungsfrage'} +

+ +
+ +
+ +
+ + + +
+
+
+
+
+ ); + } + + return ( +
+
+

ADMIN-BEREICH

+ + {!showStats ? ( +
+
+ + +
+ + + +
+

Texte bearbeiten

+ +
+ + + + + +
+
+ +
+
+

Abstimmungsoptionen

+ +
+ + {showVoteOptions && ( +
+

Aktuelle Optionen

+ +
+ {voteOptions.map((option) => ( +
+
+ {option.id}: {option.label} +
+ +
+ ))} +
+ +

Neue Option hinzufügen

+
+
+ + setNewOptionId(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + placeholder="option-id" + /> +

+ Die ID wird intern verwendet und sollte kurz und eindeutig sein. +

+
+ +
+ + setNewOptionLabel(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + placeholder="Anzeigename der Option" + /> +

+ Der Anzeigename wird den Abstimmenden angezeigt. +

+
+ + +
+ +
+

Hinweis:

+

Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.

+
+
+ )} +
+ +
+
+

Mitgliederanmeldung

+ +
+ +
+

+ Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen. +

+ +
+ +
+
+ + {showMembers && ( +
+ +
+ )} +
+ +
+

Abstimmungslinks

+ +
+ + +
+
+ + {generatedLink && ( +
+

Generierter Abstimmungslink:

+
+ {generatedLink} +
+ +
+ )} +
+ + {error && ( +
{error}
+ )} + +
+

Mehrere Abstimmungslinks generieren:

+
+ + setBulkTokenCount(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + /> +
+ +

+ Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können. +

+
+
+ + +
+

Zurücksetzen

+ +
+ +

+ Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden. +

+
+ + {error && ( +
{error}
+ )} +
+ +
+ ) : ( +
+

Abstimmungsstatistiken

+ + {stats && voteOptions.length > 0 && ( +
+
+ {voteOptions.map((option) => ( +
+
{stats[option.id] || 0}
+
{option.label}
+
+ ))} +
+ +
+
{stats.total}
+
Gesamtstimmen
+
+ + {stats.total > 0 && ( +
+

Ergebnisübersicht

+
+
+ {voteOptions.map((option, index) => { + const percentage = ((stats[option.id] || 0) / stats.total) * 100; + // Define colors for different options + const colors = [ + 'bg-[#0057a6]', // Blue for first option (usually yes) + 'bg-red-500', // Red for second option (usually no) + 'bg-gray-500' // Gray for third option (usually abstain) + ]; + // Use a default color for additional options + const color = index < colors.length ? colors[index] : 'bg-green-500'; + + return ( +
+ ); + })} +
+
+
+ {voteOptions.map((option, index) => { + const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100); + // Define text colors for different options + const textColors = [ + 'text-[#0057a6]', // Blue for first option (usually yes) + 'text-red-600', // Red for second option (usually no) + 'text-gray-600' // Gray for third option (usually abstain) + ]; + // Use a default color for additional options + const textColor = index < textColors.length ? textColors[index] : 'text-green-600'; + + return ( + + {percentage}% {option.label} + + ); + })} +
+
+ )} +
+ )} + + {comments.length > 0 && ( +
+

Kommentare der Teilnehmer

+
+ {comments.map((comment, index) => ( +
+
+ + {voteOptions.find(option => option.id === comment.vote)?.label || comment.vote} + + + {new Date(comment.timestamp).toLocaleDateString('de-DE')} + +
+

{comment.comment}

+
+ ))} +
+
+ )} + + +
+ )} +
+
+ ); +} diff --git a/src/app/api/editable-text/route.ts b/src/app/api/editable-text/route.ts new file mode 100644 index 0000000..3e853c4 --- /dev/null +++ b/src/app/api/editable-text/route.ts @@ -0,0 +1,131 @@ +import { checkAdminAuth } from '@/lib/auth'; +import fs from 'fs'; +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; + +// Path to the editable text file +const TEXT_FILE = path.join(process.cwd(), 'data', 'editable_text.json'); + +// Ensure the data directory exists +function ensureDataDirectory() { + const dataDir = path.join(process.cwd(), 'data'); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } +} + +// Get all editable texts +function getEditableTexts() { + ensureDataDirectory(); + + if (!fs.existsSync(TEXT_FILE)) { + // Default texts + const defaultTexts = [ + { + id: 'welcome-text', + content: '

Herzlich willkommen bei der Online-Abstimmungsplattform des SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING.

Diese Plattform ermöglicht es Mitgliedern, sicher und bequem über Vereinsangelegenheiten abzustimmen.

' + }, + { + id: 'current-vote-text', + content: '

Derzeit läuft eine Abstimmung zur Änderung der Vereinssatzung.

Bitte nutzen Sie den Ihnen zugesandten Link, um an der Abstimmung teilzunehmen.

Bei Fragen wenden Sie sich bitte an den Vorstand.

' + }, + { + id: 'vote-question', + content: '

Stimmen Sie der vorgeschlagenen Änderung der Vereinssatzung zu?

' + }, + { + id: 'vote-options', + content: [ + { + id: 'yes', + label: 'Ja, ich stimme zu' + }, + { + id: 'no', + label: 'Nein, ich stimme nicht zu' + }, + { + id: 'abstain', + label: 'Ich enthalte mich' + } + ] + } + ]; + + fs.writeFileSync(TEXT_FILE, JSON.stringify(defaultTexts, null, 2)); + return defaultTexts; + } + + const data = fs.readFileSync(TEXT_FILE, 'utf-8'); + return JSON.parse(data); +} + +// Save editable texts +function saveEditableTexts(texts: any[]) { + ensureDataDirectory(); + fs.writeFileSync(TEXT_FILE, JSON.stringify(texts, null, 2)); +} + +// GET handler to retrieve all editable texts +export async function GET() { + try { + const texts = getEditableTexts(); + return NextResponse.json({ texts }); + } catch (error) { + console.error('Error getting editable texts:', error); + return NextResponse.json( + { error: 'Failed to get editable texts' }, + { status: 500 } + ); + } +} + +// POST handler to update an editable text (requires admin authentication) +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + // Check for admin auth + const { password, } = body; + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id, content } = body; + + if (!id || !content) { + return NextResponse.json( + { error: 'ID and content are required' }, + { status: 400 } + ); + } + + // Get current texts + const texts = getEditableTexts(); + + // Find and update the text with the given ID + const textIndex = texts.findIndex((text: any) => text.id === id); + + if (textIndex === -1) { + // Text not found, add new + texts.push({ id, content }); + } else { + // Update existing text + texts[textIndex].content = content; + } + + // Save updated texts + saveEditableTexts(texts); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating editable text:', error); + return NextResponse.json( + { error: 'Failed to update editable text' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/generate-bulk-tokens/route.ts b/src/app/api/generate-bulk-tokens/route.ts new file mode 100644 index 0000000..68b4532 --- /dev/null +++ b/src/app/api/generate-bulk-tokens/route.ts @@ -0,0 +1,57 @@ +import { checkAdminAuth, generateRandomToken } from '@/lib/auth'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + // Check for admin auth + const { password } = body; + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Parse request body + const count = parseInt(body.count) || 10; + + // Limit the number of tokens that can be generated at once + if (count < 1 || count > 1000) { + return NextResponse.json( + { error: 'Count must be between 1 and 1000' }, + { status: 400 } + ); + } + + // Generate tokens and URLs + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin; + const tokens = []; + + for (let i = 0; i < count; i++) { + const token = await generateRandomToken(); + const voteUrl = `${baseUrl}/vote?token=${encodeURIComponent(token)}`; + tokens.push({ token, voteUrl }); + } + + // Create CSV content + const csvHeader = 'Token,Abstimmungslink\n'; + const csvRows = tokens.map(({ token, voteUrl }) => `"${token}","${voteUrl}"`).join('\n'); + const csvContent = csvHeader + csvRows; + + // Return CSV as text response + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="abstimmungslinks_${new Date().toISOString().slice(0, 10)}.csv"`, + }, + }); + } catch (error) { + console.error('Error generating bulk tokens:', error); + return NextResponse.json( + { error: 'Failed to generate tokens' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/generate-token/route.ts b/src/app/api/generate-token/route.ts new file mode 100644 index 0000000..172075f --- /dev/null +++ b/src/app/api/generate-token/route.ts @@ -0,0 +1,35 @@ +import { checkAdminAuth, generateRandomToken } from '@/lib/auth'; +import { NextRequest, NextResponse } from 'next/server'; + +// Get admin password from environment variable or use default for development +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'schafwaschener-segelverein-admin'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + // Check for admin auth + const { password } = body; + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Generate a new voting token + const token = await generateRandomToken(); + + // Create the voting URL with the token + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin; + const voteUrl = `${baseUrl}/vote?token=${encodeURIComponent(token)}`; + + return NextResponse.json({ token, voteUrl });; + } catch (error) { + console.error('Error generating token:', error); + return NextResponse.json( + { error: 'Failed to generate token' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/member-login/route.ts b/src/app/api/member-login/route.ts new file mode 100644 index 0000000..6fae366 --- /dev/null +++ b/src/app/api/member-login/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateRandomToken } from '@/lib/auth'; +import { verifyMemberCredentials, getSettings } from '@/lib/server-auth'; + +export async function POST(request: NextRequest) { + try { + // Check if member authentication is enabled + const settings = getSettings(); + if (!settings.memberAuthEnabled) { + return NextResponse.json( + { error: 'Mitgliederanmeldung ist derzeit deaktiviert' }, + { status: 403 } + ); + } + + // Get credentials from request + const { memberNumber, password } = await request.json(); + + if (!memberNumber || !password) { + return NextResponse.json( + { error: 'Mitgliedsnummer und Passwort sind erforderlich' }, + { status: 400 } + ); + } + + // Verify credentials + const { valid, hasVoted } = verifyMemberCredentials(memberNumber, password); + + if (!valid) { + return NextResponse.json( + { error: 'Ungültige Mitgliedsnummer oder Passwort' }, + { status: 401 } + ); + } + + // Check if member has already voted + if (hasVoted) { + return NextResponse.json( + { error: 'Sie haben bereits abgestimmt' }, + { status: 403 } + ); + } + + // Generate a token for voting + const token = await generateRandomToken(memberNumber); + + // Create the voting URL with the token + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || request.nextUrl.origin; + const voteUrl = `${baseUrl}/vote?token=${encodeURIComponent(token)}`; + + // Create response with token cookie + const response = NextResponse.json({ token, voteUrl }); + + // Set the token cookie + response.cookies.set('vote-token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + + return response; + } catch (error) { + console.error('Error during member login:', error); + return NextResponse.json( + { error: 'Fehler bei der Anmeldung' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/members/route.ts b/src/app/api/members/route.ts new file mode 100644 index 0000000..8bdb6cf --- /dev/null +++ b/src/app/api/members/route.ts @@ -0,0 +1,150 @@ +import { checkAdminAuth } from '@/lib/auth'; +import { addMember, deleteMember, getMemberCredentials, updateMember } from '@/lib/server-auth'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + // Check for admin auth + let isAuthenticated = await checkAdminAuth(); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + + // Get all members + const members = getMemberCredentials(); + + // Remove password from response + const sanitizedMembers = members.map(({ password, ...rest }) => rest); + + return NextResponse.json({ members: sanitizedMembers }); + } catch (error) { + console.error('Error getting members:', error); + return NextResponse.json( + { error: 'Fehler beim Abrufen der Mitglieder' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + // Verify admin authentication + const body = await request.json(); + // Check for admin auth + const { password } = body; + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Get the action and data from the request + const { action, ...data } = body; + + if (!action) { + return NextResponse.json( + { error: 'Aktion ist erforderlich' }, + { status: 400 } + ); + } + + // Handle different actions + switch (action) { + case 'add': { + const { memberNumber, password } = data; + + if (!memberNumber || !password) { + return NextResponse.json( + { error: 'Mitgliedsnummer und Passwort sind erforderlich' }, + { status: 400 } + ); + } + + const success = addMember(memberNumber, password); + + if (!success) { + return NextResponse.json( + { error: 'Mitglied existiert bereits' }, + { status: 400 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Mitglied erfolgreich hinzugefügt' + }); + } + + case 'update': { + const { memberNumber, password, hasVoted } = data; + + if (!memberNumber) { + return NextResponse.json( + { error: 'Mitgliedsnummer ist erforderlich' }, + { status: 400 } + ); + } + + const success = updateMember(memberNumber, { + password: password || undefined, + hasVoted: hasVoted !== undefined ? hasVoted : undefined + }); + + if (!success) { + return NextResponse.json( + { error: 'Mitglied nicht gefunden' }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Mitglied erfolgreich aktualisiert' + }); + } + + case 'delete': { + const { memberNumber } = data; + + if (!memberNumber) { + return NextResponse.json( + { error: 'Mitgliedsnummer ist erforderlich' }, + { status: 400 } + ); + } + + const success = deleteMember(memberNumber); + + if (!success) { + return NextResponse.json( + { error: 'Mitglied nicht gefunden' }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Mitglied erfolgreich gelöscht' + }); + } + + default: + return NextResponse.json( + { error: 'Ungültige Aktion' }, + { status: 400 } + ); + } + } catch (error) { + console.error('Error managing members:', error); + return NextResponse.json( + { error: 'Fehler bei der Mitgliederverwaltung' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/public-stats/route.ts b/src/app/api/public-stats/route.ts new file mode 100644 index 0000000..589637a --- /dev/null +++ b/src/app/api/public-stats/route.ts @@ -0,0 +1,17 @@ +import { getSurveyStats } from '@/lib/survey'; +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + // Get survey statistics without requiring authentication + const stats = getSurveyStats(); + + return NextResponse.json({ stats }); + } catch (error) { + console.error('Error getting public stats:', error); + return NextResponse.json( + { error: 'Failed to get survey statistics' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/reset-votes/route.ts b/src/app/api/reset-votes/route.ts new file mode 100644 index 0000000..e764d53 --- /dev/null +++ b/src/app/api/reset-votes/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { checkAdminAuth } from '@/lib/auth'; +import { resetMemberVotingStatus } from '@/lib/server-auth'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + // Check for admin auth + const { password } = body; + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Reset responses.json to an empty array + const responsesPath = path.join(process.cwd(), 'data', 'responses.json'); + fs.writeFileSync(responsesPath, '[]', 'utf8'); + + // Reset used_tokens.json to an empty array + const usedTokensPath = path.join(process.cwd(), 'data', 'used_tokens.json'); + fs.writeFileSync(usedTokensPath, '[]', 'utf8'); + + // Reset member voting status + resetMemberVotingStatus(); + + return NextResponse.json({ + success: true, + message: 'Abstimmungsdaten wurden zurückgesetzt' + }); + + } + catch (error) { + console.error('Error resetting votes:', error); + return NextResponse.json( + { error: 'Fehler beim Zurücksetzen der Abstimmungsdaten' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..96da617 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSettings } from '@/lib/server-auth'; + +export async function GET(request: NextRequest) { + try { + // Get settings + const settings = getSettings(); + + return NextResponse.json({ settings }); + } catch (error) { + console.error('Error getting settings:', error); + return NextResponse.json( + { error: 'Fehler beim Abrufen der Einstellungen' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts new file mode 100644 index 0000000..46acef2 --- /dev/null +++ b/src/app/api/stats/route.ts @@ -0,0 +1,41 @@ +import { checkAdminAuth } from '@/lib/auth'; +import { getAllResponses, getSurveyStats } from '@/lib/survey'; +import { NextRequest, NextResponse } from 'next/server'; + + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { password } = body; + + // Check for admin auth + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Get survey statistics and all responses + const stats = getSurveyStats(); + const responses = getAllResponses(); + + // Filter out responses with comments, but don't include IDs to ensure anonymity + const comments = responses + .filter(response => response.comment && response.comment.trim() !== '') + .map(response => ({ + vote: response.vote, + comment: response.comment, + timestamp: response.timestamp + })); + + return NextResponse.json({ stats, comments }); + } catch (error) { + console.error('Error getting stats:', error); + return NextResponse.json( + { error: 'Failed to get survey statistics' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/submit-vote/route.ts b/src/app/api/submit-vote/route.ts new file mode 100644 index 0000000..13a9c5b --- /dev/null +++ b/src/app/api/submit-vote/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyToken } from '@/lib/server-auth'; +import { saveResponse, VoteOption, getVoteOptions } from '@/lib/survey'; + +export async function POST(request: NextRequest) { + try { + // Get the token from the request + const { token, vote, comment } = await request.json(); + + if (!token) { + return NextResponse.json( + { error: 'Token is required' }, + { status: 400 } + ); + } + + // Get available vote options + const voteOptions = getVoteOptions(); + const validOptionIds = voteOptions.map(option => option.id); + + if (!vote || !validOptionIds.includes(vote)) { + return NextResponse.json( + { error: 'Valid vote option is required' }, + { status: 400 } + ); + } + + // Verify the token + const { valid } = await verifyToken(token); + + if (!valid) { + return NextResponse.json( + { error: 'Invalid or already used token' }, + { status: 401 } + ); + } + + // Save the response + const response = saveResponse(vote as VoteOption, comment); + + return NextResponse.json({ success: true, response }); + } catch (error) { + console.error('Error submitting vote:', error); + return NextResponse.json( + { error: 'Failed to submit vote' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/toggle-member-auth/route.ts b/src/app/api/toggle-member-auth/route.ts new file mode 100644 index 0000000..98e491f --- /dev/null +++ b/src/app/api/toggle-member-auth/route.ts @@ -0,0 +1,43 @@ +import { checkAdminAuth } from '@/lib/auth'; +import { getSettings, updateSettings } from '@/lib/server-auth'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + // Check for admin auth + const { password } = body; + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + // Get current settings + const settings = getSettings(); + + // Toggle the setting + const newSettings = { + ...settings, + memberAuthEnabled: !settings.memberAuthEnabled + }; + + // Update settings + updateSettings(newSettings); + + return NextResponse.json({ + success: true, + memberAuthEnabled: newSettings.memberAuthEnabled, + message: newSettings.memberAuthEnabled + ? 'Mitgliederanmeldung wurde aktiviert' + : 'Mitgliederanmeldung wurde deaktiviert' + }); + } catch (error) { + console.error('Error toggling member auth:', error); + return NextResponse.json( + { error: 'Fehler beim Ändern der Einstellung' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/upload-members/route.ts b/src/app/api/upload-members/route.ts new file mode 100644 index 0000000..20bb3b5 --- /dev/null +++ b/src/app/api/upload-members/route.ts @@ -0,0 +1,55 @@ +import { checkAdminAuth } from '@/lib/auth'; +import { importMembersFromCSV } from '@/lib/server-auth'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { password } = body; + // Check for admin auth + let isAuthenticated = await checkAdminAuth(password); + if (!isAuthenticated) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + // Get the CSV content from the request + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { error: 'Keine Datei hochgeladen' }, + { status: 400 } + ); + } + + // Check file type + if (!file.name.endsWith('.csv')) { + return NextResponse.json( + { error: 'Nur CSV-Dateien werden unterstützt' }, + { status: 400 } + ); + } + + // Read the file content + const csvContent = await file.text(); + + // Import members from CSV + const result = importMembersFromCSV(csvContent); + + return NextResponse.json({ + success: true, + added: result.added, + skipped: result.skipped, + message: `${result.added} Mitglieder importiert, ${result.skipped} übersprungen` + }); + } catch (error) { + console.error('Error uploading members:', error); + return NextResponse.json( + { error: 'Fehler beim Importieren der Mitglieder' }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 947a048..2e8dd57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,17 +8,81 @@ :root { --background: #ffffff; --foreground: #171717; + --ssvc-blue: #0057a6; + --ssvc-light-blue: #e6f0fa; + --ssvc-dark-blue: #003b70; + --ssvc-gray: #f0f0f0; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} +/* Remove dark mode media query to prevent system dark mode from affecting the app */ body { color: var(--foreground); background: var(--background); font-family: Arial, Helvetica, sans-serif; } + +.ssvc-header { + background-color: var(--ssvc-blue); + color: white; + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.ssvc-logo-container { + display: flex; + align-items: center; +} + +.ssvc-logo-text { + margin-left: 1rem; +} + +.ssvc-main-content { + background-color: white; + padding: 2rem; + border-radius: 0; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.ssvc-button { + background-color: var(--ssvc-blue); + color: white; + padding: 0.5rem 1rem; + border: none; + border-radius: 0; + cursor: pointer; + font-weight: bold; + transition: background-color 0.3s; +} + +.ssvc-button:hover { + background-color: var(--ssvc-dark-blue); +} + +.ssvc-nav { + background-color: var(--ssvc-gray); + padding: 0.5rem 1rem; +} + +.ssvc-nav a { + color: var(--ssvc-blue); + text-decoration: none; + padding: 0.5rem 1rem; + display: inline-block; + font-weight: bold; +} + +.ssvc-nav a:hover { + background-color: var(--ssvc-light-blue); +} + +.ssvc-footer { + margin-top: 2rem; + padding: 1rem; + text-align: center; + font-size: 0.875rem; + color: #666; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..354bb46 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Link from "next/link"; +import Image from "next/image"; import "./globals.css"; const geistSans = Geist({ @@ -13,8 +15,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING - Online Voting System", + description: "Secure online voting system for SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING club members", }; export default function RootLayout({ @@ -23,11 +25,40 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} +
+
+
+
+ SSVC Logo +
+
+
+
SCHAFWASCHENER SEGELVEREIN
+
CHIEMSEE E.V.
+
RIMSTING
+
+
+
+ +
+ {children} +
+
+

© {new Date().getFullYear()} SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING. Alle Rechte vorbehalten.

+
); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ca71256 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { EditableText } from '@/components/EditableText'; + +export default function LoginPage() { + const router = useRouter(); + + const [memberNumber, setMemberNumber] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isEnabled, setIsEnabled] = useState(true); + + // Check if member authentication is enabled + useEffect(() => { + const checkSettings = async () => { + try { + const response = await fetch('/api/member-login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ memberNumber: '', password: '' }), + }); + + if (response.status === 403) { + setIsEnabled(false); + } + } catch (err) { + // Silently fail, assume enabled + } + }; + + checkSettings(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!memberNumber || !password) { + setError('Bitte geben Sie Ihre Mitgliedsnummer und Ihr Passwort ein'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/member-login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ memberNumber, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Anmeldung fehlgeschlagen'); + } + + // Redirect to voting page with token + router.push(data.voteUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + if (!isEnabled) { + return ( +
+
+

MITGLIEDERANMELDUNG

+ +
+
+
+ Die Mitgliederanmeldung ist derzeit deaktiviert. +
+ + + Zurück zur Startseite + +
+
+
+
+ ); + } + + return ( +
+
+

MITGLIEDERANMELDUNG

+ +
+
+ +
+ +
+
+ + setMemberNumber(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + required + /> +
+ + {error && ( +
{error}
+ )} + + +
+ +
+ + Zurück zur Startseite + +
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3eee014..3b2a43b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,101 +1,73 @@ -import Image from "next/image"; +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { EditableText } from '@/components/EditableText'; export default function Home() { + const [memberAuthEnabled, setMemberAuthEnabled] = useState(false); + + // Fetch settings on component mount + useEffect(() => { + const fetchSettings = async () => { + try { + const response = await fetch('/api/settings'); + if (response.ok) { + const data = await response.json(); + setMemberAuthEnabled(data.settings.memberAuthEnabled); + } + } catch (err) { + console.error('Error fetching settings:', err); + } + }; + + fetchSettings(); + }, []); return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - +
+
+

WILLKOMMEN BEI DER ONLINE-ABSTIMMUNG

+
+ + +
+

ZUGANG ZUR ABSTIMMUNG

+

+ Wenn Sie einen Abstimmungslink erhalten haben, verwenden Sie diesen bitte direkt, um auf Ihren Stimmzettel zuzugreifen. +

+ + {memberAuthEnabled && ( +
+

+ Als Mitglied können Sie sich auch mit Ihrer Mitgliedsnummer und Ihrem Passwort anmelden. +

+ + ZUR MITGLIEDERANMELDUNG + +
+ )} + +
+ + ABSTIMMUNGSERGEBNISSE + +
+
-
- +
+ +
+

AKTUELLE ABSTIMMUNG

+
+ +
+
); } diff --git a/src/app/vote/page.tsx b/src/app/vote/page.tsx new file mode 100644 index 0000000..1d78b14 --- /dev/null +++ b/src/app/vote/page.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { EditableText } from '@/components/EditableText'; +import { VoteOption, VoteOptionConfig } from '@/lib/survey'; + +export default function VotePage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const token = searchParams.get('token'); + + const [voteOptions, setVoteOptions] = useState([]); + const [selectedOption, setSelectedOption] = useState(null); + const [comment, setComment] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isSubmitted, setIsSubmitted] = useState(false); + + // Fetch vote options + useEffect(() => { + const fetchVoteOptions = async () => { + try { + const response = await fetch('/api/editable-text'); + if (response.ok) { + const data = await response.json(); + if (data.texts && Array.isArray(data.texts)) { + const voteOptionsEntry = data.texts.find((text: any) => text.id === 'vote-options'); + if (voteOptionsEntry && Array.isArray(voteOptionsEntry.content)) { + setVoteOptions(voteOptionsEntry.content); + } + } + } + } catch (error) { + console.error('Error fetching vote options:', error); + // Use default options if fetch fails + setVoteOptions([ + { id: 'yes', label: 'Ja, ich stimme zu' }, + { id: 'no', label: 'Nein, ich stimme nicht zu' }, + { id: 'abstain', label: 'Ich enthalte mich' } + ]); + } + }; + + fetchVoteOptions(); + }, []); + + // Redirect if no token is provided + useEffect(() => { + if (!token) { + router.push('/'); + } + }, [token, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedOption) { + setError('Bitte wählen Sie eine Option'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/submit-vote', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + vote: selectedOption, + comment: comment.trim() || undefined, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Fehler beim Übermitteln der Stimme'); + } + + setIsSubmitted(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); + } finally { + setIsLoading(false); + } + }; + + if (isSubmitted) { + return ( +
+
+
+
+ + + +
+

Vielen Dank!

+

+ Ihre Stimme wurde erfolgreich übermittelt. +

+
+ + + Zurück zur Startseite + +
+
+ ); + } + + return ( +
+
+

SATZUNGSÄNDERUNG - ABSTIMMUNG

+ +
+
+
+
+ +
+ +
+ {voteOptions.map((option) => ( + + ))} +
+
+ +
+ +
+