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
+
+```
+
+- [ ] **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 (
+
+ )
+}
+```
+
+- [ ] **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 (
+
+ )
+}
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 {