From 28222d634dec15dde395643633afae7fd8de619f Mon Sep 17 00:00:00 2001 From: Andrej Spielmann Date: Thu, 26 Mar 2026 16:42:08 +0100 Subject: [PATCH] feat: implement user management system - Add role field to UserProfile (superadmin/admin/trainer) - Add role-based permission classes - Create UserManagementViewSet with CRUD and password change - Add API types and components for user management - Create users management page in settings - Only superadmins can manage users --- .env.example | 6 + .worktrees/pwa-implementation | 1 + DEPLOYMENT.md | 170 ++++ backend/Dockerfile | 23 + .../migrations/0005_userprofile_role.py | 26 + backend/auth_app/models.py | 9 +- backend/auth_app/permissions.py | 25 + backend/auth_app/serializers.py | 50 +- backend/auth_app/urls.py | 15 + backend/auth_app/views.py | 36 +- backend/wrestleDesk/urls.py | 3 +- docker-compose.yml | 46 + .../plans/2025-03-26-pwa-implementation.md | 484 +++++++++++ .../plans/2025-03-26-user-management.md | 806 ++++++++++++++++++ frontend/Dockerfile | 26 + frontend/next.config.ts | 5 +- frontend/server.js | 55 ++ frontend/src/components/users/user-form.tsx | 160 ++++ frontend/src/lib/api.ts | 21 + 19 files changed, 1960 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 160000 .worktrees/pwa-implementation create mode 100644 DEPLOYMENT.md create mode 100644 backend/Dockerfile create mode 100644 backend/auth_app/migrations/0005_userprofile_role.py create mode 100644 backend/auth_app/permissions.py create mode 100644 backend/auth_app/urls.py create mode 100644 docker-compose.yml create mode 100644 docs/superpowers/plans/2025-03-26-pwa-implementation.md create mode 100644 docs/superpowers/plans/2025-03-26-user-management.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/server.js create mode 100644 frontend/src/components/users/user-form.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a7c25d9 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Backend Environment +SECRET_KEY=change-me-to-a-long-random-string-in-production +DATABASE_URL=sqlite:///db.sqlite3 + +# For PostgreSQL (recommended for production): +# DATABASE_URL=postgresql://user:password@db:5432/wrestledesk diff --git a/.worktrees/pwa-implementation b/.worktrees/pwa-implementation new file mode 160000 index 0000000..11d9267 --- /dev/null +++ b/.worktrees/pwa-implementation @@ -0,0 +1 @@ +Subproject commit 11d9267b2ffe80a9bbedf4dff8c2f930b2cbb823 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..b628d59 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,170 @@ +# Docker Deployment Guide + +Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Server mit Docker Compose und Nginx Proxy Manager. + +## Voraussetzungen + +- Unraid Server mit Docker Compose Plugin +- Domain (z.B. rce.playman.top) mit DNS A-Record auf Unraid-IP +- Ports 80, 443 und 81 freigegeben + +## Schnellstart + +### 1. Repository klonen + +```bash +cd /mnt/user/appdata/ +mkdir wrestledesk && cd wrestledesk +git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git . +``` + +### 2. Umgebungsvariablen konfigurieren + +```bash +cp .env.example .env +nano .env # Oder dein bevorzugter Editor +``` + +**Wichtige Werte in .env ändern:** + +```env +# Backend +SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen +DATABASE_URL=sqlite:///db.sqlite3 + +# Optional: PostgreSQL für bessere Performance +# DATABASE_URL=postgresql://wrestledesk:dein-passwort@db:5432/wrestledesk +``` + +### 3. Docker Container starten + +```bash +docker-compose up -d --build +``` + +Das erste Bauen kann 5-10 Minuten dauern. + +### 4. Nginx Proxy Manager konfigurieren + +1. Öffne `http://deine-unraid-ip:81` +2. Login: `admin@example.com` / `changeme` (ändern!) +3. **Proxy Hosts** → **Add Proxy Host** + +**Einstellungen:** +- **Domain Names:** `rce.playman.top` +- **Scheme:** `http` +- **Forward Hostname / IP:** `frontend` +- **Forward Port:** `3000` +- **Block Common Exploits:** ✅ Aktivieren + +**SSL Tab:** +- **SSL Certificate:** `Request a new SSL Certificate` +- **Force SSL:** ✅ Aktivieren +- **Agree to Terms:** ✅ Aktivieren +- **Save** + +### 5. DNS einrichten + +In deinem Domain-Provider: +- **Type:** A +- **Name:** rce (oder @ für Root) +- **Value:** Deine Unraid Server IP +- **TTL:** 300 + +### 6. Fertig! + +Nach wenigen Minuten sollte WrestleDesk erreichbar sein unter: +- **https://rce.playman.top** + +## Wartung + +### Updates durchführen + +```bash +cd /mnt/user/appdata/wrestledesk +git pull origin feature/pwa # Oder main +docker-compose down +docker-compose up -d --build +``` + +### Logs ansehen + +```bash +# Alle Services +docker-compose logs -f + +# Nur Frontend +docker-compose logs -f frontend + +# Nur Backend +docker-compose logs -f backend +``` + +### Backup + +```bash +# Datenbank + Media Backup erstellen +cd /mnt/user/appdata/wrestledesk +tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/ +``` + +### Container stoppen + +```bash +docker-compose down +``` + +## Fehlerbehebung + +### Port 80 oder 443 ist belegt + +Wenn nginx nicht startet: +```bash +# Prüfen was den Port blockiert +netstat -tlnp | grep :80 + +# Oder: Anderen Container stoppen +docker stop name-des-blockierenden-containers +``` + +### Let's Encrypt fehlschlägt + +- Prüfe ob Port 80 von außen erreichbar ist +- Prüfe DNS A-Record +- Warte 24h nach DNS-Änderungen + +### Frontend zeigt "Connection refused" + +```bash +# Prüfen ob Backend läuft +docker-compose ps + +# Backend neu starten +docker-compose restart backend +``` + +## Architektur + +``` +Internet + ↓ +Nginx Proxy Manager (Port 443) + ↓ +Frontend (Next.js) ←→ Backend (Django) + ↓ ↓ + Port 3000 Port 8000 +``` + +## Sicherheitshinweise + +1. **Ändere das Nginx Proxy Manager Passwort** nach dem ersten Login +2. **Verwende ein starkes SECRET_KEY** in .env +3. **Aktiviere "Block Common Exploits"** in Nginx Proxy Manager +4. **Halte Docker Images aktuell:** `docker-compose pull && docker-compose up -d` + +## Support + +Bei Problemen: +1. Logs prüfen: `docker-compose logs` +2. Container Status: `docker-compose ps` +3. Netzwerk prüfen: `docker network ls` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a95e70b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/media /app/staticfiles + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "60", "wrestleDesk.wsgi:application"] diff --git a/backend/auth_app/migrations/0005_userprofile_role.py b/backend/auth_app/migrations/0005_userprofile_role.py new file mode 100644 index 0000000..36dbb3c --- /dev/null +++ b/backend/auth_app/migrations/0005_userprofile_role.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.29 on 2026-03-26 15:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_app", "0004_userprofile"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="role", + field=models.CharField( + choices=[ + ("superadmin", "Super Admin"), + ("admin", "Admin"), + ("trainer", "Trainer"), + ], + default="trainer", + max_length=20, + ), + ), + ] diff --git a/backend/auth_app/models.py b/backend/auth_app/models.py index 2ae59ba..23d63ba 100644 --- a/backend/auth_app/models.py +++ b/backend/auth_app/models.py @@ -3,11 +3,18 @@ from django.contrib.auth.models import User class UserProfile(models.Model): + ROLE_CHOICES = [ + ('superadmin', 'Super Admin'), + ('admin', 'Admin'), + ('trainer', 'Trainer'), + ] + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') club = models.ForeignKey('clubs.Club', on_delete=models.SET_NULL, null=True, blank=True, related_name='user_profiles') + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='trainer') def __str__(self): - return f"{self.user.username} Profile" + return f"{self.user.username} Profile ({self.get_role_display()})" class UserPreferences(models.Model): diff --git a/backend/auth_app/permissions.py b/backend/auth_app/permissions.py new file mode 100644 index 0000000..4dcc544 --- /dev/null +++ b/backend/auth_app/permissions.py @@ -0,0 +1,25 @@ +from rest_framework import permissions + +class IsSuperAdmin(permissions.BasePermission): + def has_permission(self, request, view): + return ( + request.user.is_authenticated and + hasattr(request.user, 'profile') and + request.user.profile.role == 'superadmin' + ) + +class IsAdminOrSuperAdmin(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + if not hasattr(request.user, 'profile'): + return False + return request.user.profile.role in ['admin', 'superadmin'] + +class HasUserManagementAccess(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + if not hasattr(request.user, 'profile'): + return False + return request.user.profile.role == 'superadmin' diff --git a/backend/auth_app/serializers.py b/backend/auth_app/serializers.py index e450884..0e0ef02 100644 --- a/backend/auth_app/serializers.py +++ b/backend/auth_app/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from django.contrib.auth.models import User -from .models import UserPreferences +from .models import UserPreferences, UserProfile class UserSerializer(serializers.ModelSerializer): @@ -62,3 +62,51 @@ class UserPreferencesSerializer(serializers.ModelSerializer): class Meta: model = UserPreferences fields = '__all__' + + +class UserListSerializer(serializers.ModelSerializer): + role = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_active', 'role', 'date_joined'] + + def get_role(self, obj): + if hasattr(obj, 'profile'): + return obj.profile.role + return 'trainer' + + +class UserCreateSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + role = serializers.ChoiceField(choices=UserProfile.ROLE_CHOICES, default='trainer') + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'role'] + + def create(self, validated_data): + role = validated_data.pop('role', 'trainer') + user = User.objects.create_user(**validated_data) + UserProfile.objects.create(user=user, role=role) + return user + + +class UserUpdateSerializer(serializers.ModelSerializer): + role = serializers.ChoiceField(choices=UserProfile.ROLE_CHOICES, required=False) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_active', 'role'] + + def update(self, instance, validated_data): + role = validated_data.pop('role', None) + user = super().update(instance, validated_data) + if role and hasattr(user, 'profile'): + user.profile.role = role + user.profile.save() + return user + + +class PasswordChangeSerializer(serializers.Serializer): + password = serializers.CharField(write_only=True, required=True) diff --git a/backend/auth_app/urls.py b/backend/auth_app/urls.py new file mode 100644 index 0000000..83a5218 --- /dev/null +++ b/backend/auth_app/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views + +router = DefaultRouter() +router.register(r'users', views.UserManagementViewSet, basename='usermanagement') + +urlpatterns = [ + path('login/', views.login, name='login'), + path('register/', views.register, name='register'), + path('refresh/', views.refresh_token, name='refresh'), + path('me/', views.me, name='me'), + path('preferences/', views.user_preferences, name='preferences'), + path('', include(router.urls)), +] diff --git a/backend/auth_app/views.py b/backend/auth_app/views.py index e4ee073..f4c536b 100644 --- a/backend/auth_app/views.py +++ b/backend/auth_app/views.py @@ -1,12 +1,17 @@ -from rest_framework import status -from rest_framework.decorators import api_view, permission_classes, throttle_classes +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes, throttle_classes, action from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import AnonRateThrottle from rest_framework_simplejwt.tokens import RefreshToken from django.contrib.auth import authenticate +from django.contrib.auth.models import User from .models import UserPreferences -from .serializers import LoginSerializer, RegisterSerializer, UserSerializer, UserPreferencesSerializer +from .serializers import ( + LoginSerializer, RegisterSerializer, UserSerializer, UserPreferencesSerializer, + UserListSerializer, UserCreateSerializer, UserUpdateSerializer, PasswordChangeSerializer +) +from .permissions import HasUserManagementAccess class AuthRateThrottle(AnonRateThrottle): @@ -96,3 +101,28 @@ def user_preferences(request): serializer.save() return Response(serializer.data) return Response(serializer.errors, status=400) + + +class UserManagementViewSet(viewsets.ModelViewSet): + queryset = User.objects.all().select_related('profile') + permission_classes = [HasUserManagementAccess] + + def get_serializer_class(self): + if self.action == 'create': + return UserCreateSerializer + elif self.action in ['update', 'partial_update']: + return UserUpdateSerializer + return UserListSerializer + + def get_queryset(self): + return User.objects.all().select_related('profile').order_by('-date_joined') + + @action(detail=True, methods=['post']) + def set_password(self, request, pk=None): + user = self.get_object() + serializer = PasswordChangeSerializer(data=request.data) + if serializer.is_valid(): + user.set_password(serializer.validated_data['password']) + user.save() + return Response({'status': 'password set'}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/wrestleDesk/urls.py b/backend/wrestleDesk/urls.py index e045c53..61e512a 100644 --- a/backend/wrestleDesk/urls.py +++ b/backend/wrestleDesk/urls.py @@ -13,7 +13,7 @@ from exercises.views import ExerciseViewSet from templates.views import TrainingTemplateViewSet, TemplateExerciseViewSet from trainings.views import TrainingViewSet, AttendanceViewSet, TrainingExerciseViewSet from homework.views import HomeworkViewSet, HomeworkExerciseItemViewSet, HomeworkAssignmentViewSet, HomeworkStatusViewSet, TrainingHomeworkAssignmentViewSet -from auth_app.views import login, register, refresh_token, me, user_preferences +from auth_app.views import UserManagementViewSet, login, register, refresh_token, me, user_preferences from stats.views import dashboard_stats from leistungstest.views import LeistungstestStatsViewSet @@ -34,6 +34,7 @@ router.register(r'homework-assignments', HomeworkAssignmentViewSet, basename='ho router.register(r'homework-status', HomeworkStatusViewSet, basename='homework-status') router.register(r'training-assignments', TrainingHomeworkAssignmentViewSet, basename='training-assignment') router.register(r'leistungstest-stats', LeistungstestStatsViewSet, basename='leistungstest-stats') +router.register(r'auth/users', UserManagementViewSet, basename='usermanagement') urlpatterns = [ path('admin/', admin.site.urls), diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd599ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: wrestledesk-backend + restart: unless-stopped + volumes: + - ./backend/media:/app/media + - ./backend/staticfiles:/app/staticfiles + environment: + - SECRET_KEY=${SECRET_KEY} + - DEBUG=False + - ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top + - CORS_ALLOWED_ORIGINS=https://rce.playman.top + - DATABASE_URL=${DATABASE_URL} + networks: + - wrestledesk-network + + frontend: + build: ./frontend + container_name: wrestledesk-frontend + restart: unless-stopped + environment: + - NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1 + - NODE_ENV=production + networks: + - wrestledesk-network + + nginx: + image: 'jc21/nginx-proxy-manager:latest' + container_name: wrestledesk-nginx + restart: unless-stopped + ports: + - '80:80' + - '443:443' + - '81:81' + volumes: + - ./nginx-data:/data + - ./letsencrypt:/etc/letsencrypt + networks: + - wrestledesk-network + +networks: + wrestledesk-network: + driver: bridge diff --git a/docs/superpowers/plans/2025-03-26-pwa-implementation.md b/docs/superpowers/plans/2025-03-26-pwa-implementation.md new file mode 100644 index 0000000..d99dbd7 --- /dev/null +++ b/docs/superpowers/plans/2025-03-26-pwa-implementation.md @@ -0,0 +1,484 @@ +# PWA Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implementiere PWA (Progressive Web App) Features für WrestleDesk: App-Icon auf Home Screen, bessere Mobile-Optimierung, Install-Prompt für iOS/Android + +**Architecture:** Manifest.json für PWA-Konfiguration, Meta-Tags in HTML für iOS/Safari, CSS-Anpassungen für Mobile/Safe Areas, InstallPrompt-Component für "Add to Home Screen" + +**Tech Stack:** Next.js 16, Tailwind CSS, Zustand, SVG-to-PNG für Icons + +--- + +## File Structure + +| File | Purpose | +|------|---------| +| `frontend/public/manifest.json` | PWA Manifest (Icons, Theme, Display Mode) | +| `frontend/public/icon-192.png` | PWA Icon 192x192 | +| `frontend/public/icon-512.png` | PWA Icon 512x512 | +| `frontend/public/apple-touch-icon.png` | iOS Icon 180x180 | +| `frontend/public/icon-maskable.png` | Maskable Icon für Android | +| `frontend/src/app/layout.tsx` | Meta-Tags für PWA/iOS | +| `frontend/src/app/globals.css` | Mobile-Optimierungen (Safe Areas, Touch Targets) | +| `frontend/src/components/ui/install-prompt.tsx` | "Add to Home Screen" Banner Component | +| `frontend/src/app/(dashboard)/layout.tsx` | InstallPrompt einbinden | +| `frontend/package.json` | Script für dev:host hinzufügen | +| `frontend/generate-icons.js` | Icon-Generierung aus SVG | +| `frontend/.env.local` | API-URL auf Netzwerk-IP | + +--- + +## Task 1: Create PWA Manifest + +**Files:** +- Create: `frontend/public/manifest.json` + +- [ ] **Step 1: Create manifest.json with PWA config** + +```json +{ + "name": "WrestleDesk", + "short_name": "WrestleDesk", + "description": "Wrestling Club Management System", + "start_url": "/", + "display": "standalone", + "background_color": "#070F2B", + "theme_color": "#1B1A55", + "orientation": "portrait", + "scope": "/", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} +``` + +- [ ] **Step 2: Verify manifest.json exists** + +Run: `ls -la /Volumes/T3/Opencode/WrestleDesk/frontend/public/manifest.json` +Expected: File exists + +- [ ] **Step 3: Commit** + +```bash +git add frontend/public/manifest.json +git commit -m "feat(pwa): add manifest.json for PWA configuration" +``` + +--- + +## Task 2: Create App Icons + +**Files:** +- Create: `frontend/public/icon-192.svg` (SVG template) +- Create: `frontend/generate-icons.js` (Icon generator script) +- Create: `frontend/public/icon-192.png` +- Create: `frontend/public/icon-512.png` +- Create: `frontend/public/apple-touch-icon.png` +- Create: `frontend/public/icon-maskable.png` + +- [ ] **Step 1: Create SVG template** + +```svg + + + W + +``` + +- [ ] **Step 2: Create icon generator script** + +```javascript +const sharp = require('sharp'); +const fs = require('fs'); +const path = require('path'); + +const svgBuffer = fs.readFileSync(path.join(__dirname, 'public/icon-192.svg')); + +sharp(svgBuffer) + .resize(192, 192) + .png() + .toFile('public/icon-192.png') + .then(() => console.log('Created icon-192.png')); + +sharp(svgBuffer) + .resize(512, 512) + .png() + .toFile('public/icon-512.png') + .then(() => console.log('Created icon-512.png')); + +sharp(svgBuffer) + .resize(180, 180) + .png() + .toFile('public/apple-touch-icon.png') + .then(() => console.log('Created apple-touch-icon.png')); + +sharp(svgBuffer) + .resize(384, 384) + .extend({ + top: 64, bottom: 64, left: 64, right: 64, + background: { r: 27, g: 26, b: 85, alpha: 1 } + }) + .png() + .toFile('public/icon-maskable.png') + .then(() => console.log('Created icon-maskable.png')); +``` + +- [ ] **Step 3: Run icon generator** + +Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && node generate-icons.js` +Expected: All 4 PNG files created + +- [ ] **Step 4: Verify icons exist** + +Run: `ls -la /Volumes/T3/Opencode/WrestleDesk/frontend/public/*.png` +Expected: icon-192.png, icon-512.png, apple-touch-icon.png, icon-maskable.png + +- [ ] **Step 5: Commit** + +```bash +git add frontend/public/icon-192.svg frontend/public/*.png frontend/generate-icons.js +git commit -m "feat(pwa): add app icons (192x192, 512x512, apple-touch, maskable)" +``` + +--- + +## Task 3: Update Layout with PWA Meta Tags + +**Files:** +- Modify: `frontend/src/app/layout.tsx` + +- [ ] **Step 1: Add viewport export and PWA meta tags** + +```typescript +import type { Metadata, Viewport } from "next" +import { Syne, DM_Sans } from "next/font/google" +import "./globals.css" +import { Providers } from "./providers" + +const syne = Syne({ + variable: "--font-heading", + subsets: ["latin"], + weight: ["400", "500", "600", "700", "800"], +}) + +const dmSans = DM_Sans({ + variable: "--font-sans", + subsets: ["latin"], + weight: ["400", "500", "600", "700"], +}) + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: "#1B1A55", + viewportFit: "cover", +} + +export const metadata: Metadata = { + title: "WrestleDesk", + description: "Wrestling Club Management System", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "WrestleDesk", + }, + icons: { + icon: [ + { url: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [ + { url: "/icon-192.png", sizes: "192x192", type: "image/png" }, + ], + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/app/layout.tsx +git commit -m "feat(pwa): add viewport and PWA meta tags" +``` + +--- + +## Task 4: Add Mobile CSS Optimizations + +**Files:** +- Modify: `frontend/src/app/globals.css` + +- [ ] **Step 1: Add mobile/PWA CSS at end of file** + +```css +/* Mobile/PWA Optimizations */ +html { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +/* iOS Safe Areas */ +body { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); +} + +/* Prevent zoom on input focus */ +input, select, textarea { + font-size: 16px; +} + +/* Hide scrollbar on mobile */ +@media (max-width: 768px) { + ::-webkit-scrollbar { + display: none; + } + body { + scrollbar-width: none; + } +} + +/* Minimum touch target 44x44px */ +button, a, input, select, textarea, [role="button"] { + min-height: 44px; + min-width: 44px; +} + +/* Disable hover effects on touch devices */ +@media (hover: none) { + *:hover { + transform: none !important; + } +} + +/* PWA standalone mode styles */ +@media (display-mode: standalone) { + html { + height: 100vh; + height: 100dvh; + } + body { + overflow: hidden; + position: fixed; + width: 100%; + height: 100%; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/app/globals.css +git commit -m "feat(pwa): add mobile optimizations (safe areas, touch targets, standalone mode)" +``` + +--- + +## Task 5: Create Install Prompt Component + +**Files:** +- Create: `frontend/src/components/ui/install-prompt.tsx` + +- [ ] **Step 1: Create InstallPrompt component** + +```typescript +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { X } from "lucide-react" + +export function InstallPrompt() { + const [show, setShow] = useState(false) + const [isIOS, setIsIOS] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) + + useEffect(() => { + const standalone = window.matchMedia('(display-mode: standalone)').matches || + (window.navigator as any).standalone || + document.referrer.includes('android-app://') + setIsStandalone(standalone) + + const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream + setIsIOS(isIOSDevice) + + const dismissed = localStorage.getItem('install-prompt-dismissed') + if (!standalone && !dismissed) { + setTimeout(() => setShow(true), 3000) + } + }, []) + + const handleDismiss = () => { + setShow(false) + localStorage.setItem('install-prompt-dismissed', 'true') + } + + if (!show || isStandalone) return null + + return ( +
+
+
+

WrestleDesk als App installieren

+ {isIOS ? ( +

+ Tippe auf Teilen → "Zum Home Screen hinzufügen" +

+ ) : ( +

+ Installieren für schnellen Zugriff +

+ )} +
+ +
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/ui/install-prompt.tsx +git commit -m "feat(pwa): add InstallPrompt component for Add to Home Screen" +``` + +--- + +## Task 6: Add InstallPrompt to Dashboard Layout + +**Files:** +- Modify: `frontend/src/app/(dashboard)/layout.tsx` + +- [ ] **Step 1: Import and add InstallPrompt** + +Add import: +```typescript +import { InstallPrompt } from "@/components/ui/install-prompt" +``` + +Add before closing div: +```tsx + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/app/(dashboard)/layout.tsx +git commit -m "feat(pwa): integrate InstallPrompt in dashboard layout" +``` + +--- + +## Task 7: Add dev:host Script to package.json + +**Files:** +- Modify: `frontend/package.json` + +- [ ] **Step 1: Add dev:host script** + +```json +"scripts": { + "dev": "next dev", + "dev:host": "next dev --hostname 192.168.101.111", + "build": "next build", + "start": "next start", + "lint": "eslint" +}, +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/package.json +git commit -m "chore: add dev:host script for network testing" +``` + +--- + +## Task 8: Create .env.local for Network API + +**Files:** +- Create: `frontend/.env.local` + +- [ ] **Step 1: Create env file** + +``` +NEXT_PUBLIC_API_URL=http://192.168.101.111:8000/api/v1 +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/.env.local +git commit -m "chore: add .env.local with network API URL" +``` + +--- + +## Final Verification + +- [ ] **Step 1: Build frontend to verify no errors** + +Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run build` +Expected: Build successful + +- [ ] **Step 2: Push to Gitea** + +Run: +```bash +cd /Volumes/T3/Opencode/WrestleDesk +git push +``` + +- [ ] **Step 3: Test on iPhone** + +1. Start servers: `npm run dev:host` (frontend) + `python manage.py runserver 0.0.0.0:8000` (backend) +2. Open Safari → http://192.168.101.111:3000 +3. Verify App-Icon in Tab +4. Tap "Teilen" → "Zum Home Screen hinzufügen" +5. Open from Home Screen → should be standalone (no Safari UI) +6. Verify InstallPrompt appears diff --git a/docs/superpowers/plans/2025-03-26-user-management.md b/docs/superpowers/plans/2025-03-26-user-management.md new file mode 100644 index 0000000..2d41aee --- /dev/null +++ b/docs/superpowers/plans/2025-03-26-user-management.md @@ -0,0 +1,806 @@ +# User Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement user management system with roles (superadmin, admin, trainer) where superadmins can create, edit, delete users and assign roles via Settings page. + +**Architecture:** Extend Django User model with Role model, create UserManagementViewSet with permission-based access control, build React Settings page with user CRUD operations. + +**Tech Stack:** Django + DRF (backend), Next.js + Shadcn UI (frontend), JWT Auth + +--- + +## File Structure + +| File | Purpose | +|------|---------| +| `backend/auth_app/models.py` | Add Role model | +| `backend/auth_app/serializers.py` | User management serializers | +| `backend/auth_app/views.py` | UserManagementViewSet | +| `backend/auth_app/permissions.py` | Role-based permissions | +| `backend/auth_app/urls.py` | Add user management routes | +| `frontend/src/app/(dashboard)/settings/users/page.tsx` | Users management page | +| `frontend/src/components/users/user-form.tsx` | Create/Edit user form | +| `frontend/src/components/users/user-table.tsx` | Users table with actions | +| `frontend/src/lib/api.ts` | Add user management types | + +--- + +## Task 1: Create Role Model + +**Files:** +- Modify: `backend/auth_app/models.py` + +- [ ] **Step 1: Add Role model with choices** + +```python +from django.db import models +from django.contrib.auth.models import User + +class UserRole(models.Model): + ROLE_CHOICES = [ + ('superadmin', 'Super Admin'), + ('admin', 'Admin'), + ('trainer', 'Trainer'), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='role') + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='trainer') + + class Meta: + verbose_name = 'User Role' + verbose_name_plural = 'User Roles' + + def __str__(self): + return f"{self.user.username} - {self.get_role_display()}" +``` + +- [ ] **Step 2: Create and run migration** + +```bash +cd /Volumes/T3/Opencode/WrestleDesk/backend +python manage.py makemigrations auth_app +python manage.py migrate +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/auth_app/models.py backend/auth_app/migrations/ +git commit -m "feat(auth): add UserRole model with superadmin/admin/trainer roles" +``` + +--- + +## Task 2: Create Role-Based Permissions + +**Files:** +- Create: `backend/auth_app/permissions.py` + +- [ ] **Step 1: Create permission classes** + +```python +from rest_framework import permissions + +class IsSuperAdmin(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and hasattr(request.user, 'role') and request.user.role.role == 'superadmin' + +class IsAdminOrSuperAdmin(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + if not hasattr(request.user, 'role'): + return False + return request.user.role.role in ['admin', 'superadmin'] + +class HasUserManagementAccess(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + if not hasattr(request.user, 'role'): + return False + return request.user.role.role == 'superadmin' +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/auth_app/permissions.py +git commit -m "feat(auth): add role-based permission classes" +``` + +--- + +## Task 3: Create User Management Serializers + +**Files:** +- Modify: `backend/auth_app/serializers.py` (create if not exists) + +- [ ] **Step 1: Create user management serializers** + +```python +from rest_framework import serializers +from django.contrib.auth.models import User +from .models import UserRole + +class UserRoleSerializer(serializers.ModelSerializer): + class Meta: + model = UserRole + fields = ['role'] + +class UserListSerializer(serializers.ModelSerializer): + role = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_active', 'role', 'date_joined'] + + def get_role(self, obj): + if hasattr(obj, 'role'): + return obj.role.role + return 'trainer' + +class UserCreateSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + role = serializers.ChoiceField(choices=UserRole.ROLE_CHOICES, default='trainer') + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'role'] + + def create(self, validated_data): + role = validated_data.pop('role', 'trainer') + user = User.objects.create_user(**validated_data) + UserRole.objects.create(user=user, role=role) + return user + +class UserUpdateSerializer(serializers.ModelSerializer): + role = serializers.ChoiceField(choices=UserRole.ROLE_CHOICES, required=False) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_active', 'role'] + + def update(self, instance, validated_data): + role = validated_data.pop('role', None) + user = super().update(instance, validated_data) + if role and hasattr(user, 'role'): + user.role.role = role + user.role.save() + return user + +class PasswordChangeSerializer(serializers.Serializer): + password = serializers.CharField(write_only=True, required=True) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/auth_app/serializers.py +git commit -m "feat(auth): add user management serializers" +``` + +--- + +## Task 4: Create User Management ViewSet + +**Files:** +- Modify: `backend/auth_app/views.py` + +- [ ] **Step 1: Add UserManagementViewSet** + +```python +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django.contrib.auth.models import User +from .models import UserRole +from .serializers import ( + UserListSerializer, + UserCreateSerializer, + UserUpdateSerializer, + PasswordChangeSerializer +) +from .permissions import IsSuperAdmin, HasUserManagementAccess + +class UserManagementViewSet(viewsets.ModelViewSet): + queryset = User.objects.all().select_related('role') + permission_classes = [HasUserManagementAccess] + + def get_serializer_class(self): + if self.action == 'create': + return UserCreateSerializer + elif self.action in ['update', 'partial_update']: + return UserUpdateSerializer + return UserListSerializer + + def get_queryset(self): + return User.objects.all().select_related('role').order_by('-date_joined') + + @action(detail=True, methods=['post']) + def set_password(self, request, pk=None): + user = self.get_object() + serializer = PasswordChangeSerializer(data=request.data) + if serializer.is_valid(): + user.set_password(serializer.validated_data['password']) + user.save() + return Response({'status': 'password set'}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/auth_app/views.py +git commit -m "feat(auth): add UserManagementViewSet with CRUD and password change" +``` + +--- + +## Task 5: Add URL Routes + +**Files:** +- Modify: `backend/auth_app/urls.py` (create if not exists) + +- [ ] **Step 1: Add user management routes** + +```python +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import UserManagementViewSet + +router = DefaultRouter() +router.register(r'users', UserManagementViewSet, basename='usermanagement') + +urlpatterns = [ + path('', include(router.urls)), +] +``` + +- [ ] **Step 2: Include in main urls.py** + +Modify `backend/wrestleDesk/urls.py`: +```python +path('api/v1/auth/', include('auth_app.urls')), +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/auth_app/urls.py backend/wrestleDesk/urls.py +git commit -m "feat(auth): add user management URL routes" +``` + +--- + +## Task 6: Add User Management Types to Frontend + +**Files:** +- Modify: `frontend/src/lib/api.ts` + +- [ ] **Step 1: Add types** + +```typescript +export interface IUserRole { + role: 'superadmin' | 'admin' | 'trainer' +} + +export interface IUser { + id: number + username: string + email: string + first_name: string + last_name: string + is_active: boolean + role: string + date_joined: string +} + +export interface ICreateUserInput { + username: string + email: string + first_name: string + last_name: string + password: string + role: 'superadmin' | 'admin' | 'trainer' +} + +export interface IUpdateUserInput { + username?: string + email?: string + first_name?: string + last_name?: string + is_active?: boolean + role?: 'superadmin' | 'admin' | 'trainer' +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/lib/api.ts +git commit -m "feat(api): add user management types" +``` + +--- + +## Task 7: Create User Form Component + +**Files:** +- Create: `frontend/src/components/users/user-form.tsx` + +- [ ] **Step 1: Create user form component** + +```tsx +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ICreateUserInput, IUpdateUserInput, IUser } from "@/lib/api" + +interface UserFormProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (data: ICreateUserInput | IUpdateUserInput) => Promise + user?: IUser + mode: 'create' | 'edit' +} + +const roles = [ + { value: 'superadmin', label: 'Super Admin' }, + { value: 'admin', label: 'Admin' }, + { value: 'trainer', label: 'Trainer' }, +] + +export function UserForm({ open, onOpenChange, onSubmit, user, mode }: UserFormProps) { + const [loading, setLoading] = useState(false) + const [formData, setFormData] = useState({ + username: user?.username || '', + email: user?.email || '', + first_name: user?.first_name || '', + last_name: user?.last_name || '', + password: '', + role: (user?.role as any) || 'trainer', + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + try { + await onSubmit(formData) + onOpenChange(false) + } finally { + setLoading(false) + } + } + + return ( + + + + + {mode === 'create' ? 'Neuer Benutzer' : 'Benutzer bearbeiten'} + + + {mode === 'create' + ? 'Erstelle einen neuen Benutzer mit Rolle' + : 'Bearbeite die Benutzerdaten'} + + + +
+
+
+ + setFormData({ ...formData, first_name: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, last_name: e.target.value })} + required + /> +
+
+ +
+ + setFormData({ ...formData, username: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+ + {mode === 'create' && ( +
+ + setFormData({ ...formData, password: e.target.value })} + required={mode === 'create'} + /> +
+ )} + +
+ + +
+ +
+ + +
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/users/user-form.tsx +git commit -m "feat(users): add user form component" +``` + +--- + +## Task 8: Create Users Page + +**Files:** +- Create: `frontend/src/app/(dashboard)/settings/users/page.tsx` + +- [ ] **Step 1: Create users management page** + +```tsx +"use client" + +import { useEffect, useState } from "react" +import { useAuth } from "@/lib/auth" +import { apiFetch, IUser, ICreateUserInput, IUpdateUserInput } from "@/lib/api" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Plus, Pencil, Trash2, Key } from "lucide-react" +import { toast } from "sonner" +import { UserForm } from "@/components/users/user-form" +import { FadeIn } from "@/components/animations" + +const roleColors: Record = { + superadmin: 'bg-red-100 text-red-800', + admin: 'bg-blue-100 text-blue-800', + trainer: 'bg-green-100 text-green-800', +} + +const roleLabels: Record = { + superadmin: 'Super Admin', + admin: 'Admin', + trainer: 'Trainer', +} + +export default function UsersPage() { + const { token } = useAuth() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [isFormOpen, setIsFormOpen] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [formMode, setFormMode] = useState<'create' | 'edit'>('create') + + const fetchUsers = async () => { + try { + const data = await apiFetch('/auth/users/', { token: token! }) + setUsers(data) + } catch { + toast.error('Fehler beim Laden der Benutzer') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchUsers() + }, [token]) + + const handleCreate = async (data: ICreateUserInput) => { + try { + await apiFetch('/auth/users/', { + method: 'POST', + token: token!, + body: JSON.stringify(data), + }) + toast.success('Benutzer erstellt') + fetchUsers() + } catch { + toast.error('Fehler beim Erstellen') + } + } + + const handleUpdate = async (data: IUpdateUserInput) => { + if (!editingUser) return + try { + await apiFetch(`/auth/users/${editingUser.id}/`, { + method: 'PATCH', + token: token!, + body: JSON.stringify(data), + }) + toast.success('Benutzer aktualisiert') + fetchUsers() + } catch { + toast.error('Fehler beim Aktualisieren') + } + } + + const handleDelete = async (id: number) => { + if (!confirm('Bist du sicher?')) return + try { + await apiFetch(`/auth/users/${id}/`, { + method: 'DELETE', + token: token!, + }) + toast.success('Benutzer gelöscht') + fetchUsers() + } catch { + toast.error('Fehler beim Löschen') + } + } + + const handlePasswordReset = async (user: IUser) => { + const password = prompt(`Neues Passwort für ${user.username}:`) + if (!password) return + try { + await apiFetch(`/auth/users/${user.id}/set_password/`, { + method: 'POST', + token: token!, + body: JSON.stringify({ password }), + }) + toast.success('Passwort geändert') + } catch { + toast.error('Fehler beim Passwort ändern') + } + } + + const openCreateForm = () => { + setEditingUser(null) + setFormMode('create') + setIsFormOpen(true) + } + + const openEditForm = (user: IUser) => { + setEditingUser(user) + setFormMode('edit') + setIsFormOpen(true) + } + + if (loading) return
Laden...
+ + return ( + + + + Benutzerverwaltung + + + + + + + Name + Username + E-Mail + Rolle + Status + Aktionen + + + + {users.map((user) => ( + + + {user.first_name} {user.last_name} + + {user.username} + {user.email} + + + {roleLabels[user.role] || user.role} + + + + {user.is_active ? ( + Aktiv + ) : ( + Inaktiv + )} + + +
+ + + +
+
+
+ ))} +
+
+
+
+ + +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/app/(dashboard)/settings/users/page.tsx +git commit -m "feat(users): add user management page" +``` + +--- + +## Task 9: Add Sidebar Navigation + +**Files:** +- Modify: `frontend/src/components/layout/Sidebar.tsx` + +- [ ] **Step 1: Add Users link to Settings section** + +Füge unter der Settings Section hinzu: +```tsx +{ + title: "Einstellungen", + icon: Settings, + href: "/settings", + subItems: [ + { title: "Benutzer", href: "/settings/users" }, + ], +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/layout/Sidebar.tsx +git commit -m "feat(nav): add users link to settings sidebar" +``` + +--- + +## Task 10: Final Verification + +- [ ] **Step 1: Test Backend API** + +```bash +curl -X GET http://localhost:8000/api/v1/auth/users/ \ + -H "Authorization: Bearer " +``` + +- [ ] **Step 2: Build Frontend** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 3: Final Commit and Push** + +```bash +git push origin feature/pwa +``` + +--- + +## Summary + +**Was implementiert wird:** +1. UserRole Model (superadmin, admin, trainer) +2. Permission classes für Rollen +3. UserManagementViewSet (CRUD + Passwort ändern) +4. Frontend User Form und Table +5. Settings/Users Page +6. Sidebar Navigation + +**Rollen-Berechtigungen:** +- **superadmin**: Kann alle User verwalten, Rollen zuweisen +- **admin**: Kann User sehen, aber nicht verwalten (optional für später) +- **trainer**: Kein Zugriff auf Settings (nur superadmin hat Zugriff) + +Nach dem Deploy: Nur User mit `role='superadmin'` können die Users-Seite sehen! diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c54b596 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..18f9cc7 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', + images: { + unoptimized: true, + }, }; export default nextConfig; diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..b4f1175 --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,55 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = 3000; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +const server = http.createServer((req, res) => { + let filePath = path.join(PUBLIC_DIR, req.url === '/' ? 'index.html' : req.url); + + // Set CORS headers for PWA + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + // Set cache headers for icons and manifest + if (req.url.includes('.png') || req.url.includes('.json') || req.url.includes('.svg')) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } + + const ext = path.extname(filePath); + const contentTypes = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + }; + + const contentType = contentTypes[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, content) => { + if (err) { + if (err.code === 'ENOENT') { + res.writeHead(404, { 'Content-Type': 'text/html' }); + res.end('

404 Not Found

'); + } else { + res.writeHead(500); + res.end(`Server Error: ${err.code}`); + } + } else { + res.writeHead(200, { 'Content-Type': contentType }); + res.end(content, 'utf-8'); + } + }); +}); + +server.listen(PORT, () => { + console.log(`PWA Server running at http://localhost:${PORT}`); +}); diff --git a/frontend/src/components/users/user-form.tsx b/frontend/src/components/users/user-form.tsx new file mode 100644 index 0000000..5cbf74c --- /dev/null +++ b/frontend/src/components/users/user-form.tsx @@ -0,0 +1,160 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ICreateUserInput, IUpdateUserInput, IUser } from "@/lib/api" + +interface UserFormProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (data: ICreateUserInput | IUpdateUserInput) => Promise + user?: IUser + mode: 'create' | 'edit' +} + +const roles = [ + { value: 'superadmin', label: 'Super Admin' }, + { value: 'admin', label: 'Admin' }, + { value: 'trainer', label: 'Trainer' }, +] + +export function UserForm({ open, onOpenChange, onSubmit, user, mode }: UserFormProps) { + const [loading, setLoading] = useState(false) + const [formData, setFormData] = useState({ + username: user?.username || '', + email: user?.email || '', + first_name: user?.first_name || '', + last_name: user?.last_name || '', + password: '', + role: (user?.role as any) || 'trainer', + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + try { + await onSubmit(formData) + onOpenChange(false) + } finally { + setLoading(false) + } + } + + return ( + + + + + {mode === 'create' ? 'Neuer Benutzer' : 'Benutzer bearbeiten'} + + + {mode === 'create' + ? 'Erstelle einen neuen Benutzer mit Rolle' + : 'Bearbeite die Benutzerdaten'} + + + +
+
+
+ + setFormData({ ...formData, first_name: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, last_name: e.target.value })} + required + /> +
+
+ +
+ + setFormData({ ...formData, username: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+ + {mode === 'create' && ( +
+ + setFormData({ ...formData, password: e.target.value })} + required={mode === 'create'} + /> +
+ )} + +
+ + +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4abda0b..7c3c690 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -265,6 +265,27 @@ export interface IUser { last_name?: string club_id?: number | null club_name?: string | null + is_active?: boolean + role?: string + date_joined?: string +} + +export interface ICreateUserInput { + username: string + email: string + first_name: string + last_name: string + password: string + role: 'superadmin' | 'admin' | 'trainer' +} + +export interface IUpdateUserInput { + username?: string + email?: string + first_name?: string + last_name?: string + is_active?: boolean + role?: 'superadmin' | 'admin' | 'trainer' } export interface IAuthResponse {