Compare commits

17 Commits

Author SHA1 Message Date
Andrej Spielmann 11d9267b2f feat(docker): add PostgreSQL database container 2026-03-26 16:13:16 +01:00
Andrej Spielmann 6d0312ffd7 fix: use HTTPS API URL with Nginx proxy 2026-03-26 15:59:25 +01:00
Andrej Spielmann bdcec35383 fix: use internal IP for backend API 2026-03-26 15:56:29 +01:00
Andrej Spielmann 153b83c417 docs: add Nginx Proxy Manager configuration guide
- Document reverse proxy setup for API forwarding
- Frontend public via HTTPS
- Backend internal only via Nginx proxy
2026-03-26 15:55:54 +01:00
Andrej Spielmann ee96df11bf fix: update CORS and API URL for production deployment
- Change API URL from local IP to https://rce.playman.top
- Add Unraid IP to ALLOWED_HOSTS and CORS_ALLOWED_ORIGINS
- Fix CORS policy for direct IP access
2026-03-26 15:53:25 +01:00
Andrej Spielmann fb43a8ee9c fix: add standalone output config for Docker build 2026-03-26 15:48:18 +01:00
Andrej Spielmann 0721cbf879 feat(docker): update ports to 10001 (frontend) and 10002 (backend) 2026-03-26 15:31:58 +01:00
Andrej Spielmann 7210d09821 feat(pwa): generate PNG icons 2026-03-26 15:19:42 +01:00
Andrej Spielmann d6c95a232b chore: add .env.local with network API URL 2026-03-26 15:12:49 +01:00
Andrej Spielmann 8a6db1527b chore: add dev:host script for network testing 2026-03-26 15:10:02 +01:00
Andrej Spielmann ee07a817b0 feat(pwa): integrate InstallPrompt in dashboard layout 2026-03-26 15:08:31 +01:00
Andrej Spielmann 1314982590 feat(pwa): add InstallPrompt component for Add to Home Screen 2026-03-26 15:01:26 +01:00
Andrej Spielmann 6edff1613c feat(pwa): add mobile optimizations (safe areas, touch targets, standalone mode) 2026-03-26 14:57:49 +01:00
Andrej Spielmann 80897a7a6e feat(pwa): add viewport and PWA meta tags 2026-03-26 14:54:09 +01:00
Andrej Spielmann 830908d132 feat(pwa): add icon generator script with sharp 2026-03-26 14:52:46 +01:00
Andrej Spielmann 16c6b9789f feat(pwa): add app icon resources (192x192) and generator script 2026-03-26 14:50:06 +01:00
Andrej Spielmann f96d727586 feat(pwa): add manifest.json for PWA configuration 2026-03-26 14:46:20 +01:00
30 changed files with 327 additions and 2151 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Backend Environment # Backend Environment
SECRET_KEY=change-me-to-a-long-random-string-in-production SECRET_KEY=change-me-to-a-long-random-string-in-production
DATABASE_URL=sqlite:///db.sqlite3 DB_PASSWORD=change-me-to-a-secure-password
# For PostgreSQL (recommended for production): # Optional: PostgreSQL (empfohlen für production)
# DATABASE_URL=postgresql://user:password@db:5432/wrestledesk # DATABASE_URL wird automatisch gesetzt: postgresql://wrestledesk:DB_PASSWORD@db:5432/wrestledesk
Submodule .worktrees/pwa-implementation deleted from 11d9267b2f
-43
View File
@@ -1181,49 +1181,6 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
--- ---
## Git Workflow
**IMPORTANT: Push after EVERY task completion**
When implementing features, push to Gitea after completing each individual task:
```bash
# After each task completion
git add -A
git commit -m "feat(scope): description"
git push origin feature/branch-name
```
**Why:**
- Prevents loss of work
- Enables testing on multiple devices (e.g., iPhone testing requires network-accessible code)
- Allows rollback if issues arise
- Keeps Gitea in sync with local progress
---
## PWA Implementation (COMPLETED)
**Branch:** `feature/pwa`
### What was implemented:
1. **manifest.json** - PWA configuration with icons, theme colors, display mode
2. **App Icons** - 192x192, 512x512, Apple Touch (180x180), Maskable
3. **Meta Tags** - viewport-fit: cover, theme-color, apple-web-app-capable
4. **Mobile CSS** - Safe areas, touch targets (44x44px), standalone mode styles
5. **InstallPrompt Component** - "Add to Home Screen" banner for iOS/Android
6. **dev:host Script** - `npm run dev:host` for network testing
7. **Network Config** - `.env.local` with API URL on 192.168.168.101.111
### Testing PWA on iPhone:
1. Start backend: `python manage.py runserver 0.0.0.0:8000`
2. Start frontend: `npm run dev:host` (or `npm start` after build)
3. Open Safari → http://192.168.101.111:3000
4. Tap "Teilen" → "Zum Home Screen hinzufügen"
5. App runs standalone without Safari UI
---
## Security Notes ## Security Notes
- Rate limiting on auth endpoints (5/minute) - Rate limiting on auth endpoints (5/minute)
+72 -40
View File
@@ -1,12 +1,12 @@
# Docker Deployment Guide # Docker Deployment Guide für Unraid
Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Server mit Docker Compose und Nginx Proxy Manager. Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Server mit Docker Compose. **Nginx Proxy Manager läuft bereits zentral.**
## Voraussetzungen ## Voraussetzungen
- Unraid Server mit Docker Compose Plugin - Unraid Server mit Docker Compose Plugin
- Domain (z.B. rce.playman.top) mit DNS A-Record auf Unraid-IP - Domain (z.B. rce.playman.top) mit DNS A-Record auf Unraid-IP
- Ports 80, 443 und 81 freigegeben - Nginx Proxy Manager läuft bereits (Port 81 für Admin-UI)
## Schnellstart ## Schnellstart
@@ -16,6 +16,7 @@ Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Serve
cd /mnt/user/appdata/ cd /mnt/user/appdata/
mkdir wrestledesk && cd wrestledesk mkdir wrestledesk && cd wrestledesk
git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git . git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git .
git checkout feature/pwa # Wichtig: PWA Branch verwenden
``` ```
### 2. Umgebungsvariablen konfigurieren ### 2. Umgebungsvariablen konfigurieren
@@ -31,9 +32,11 @@ nano .env # Oder dein bevorzugter Editor
# Backend # Backend
SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen
DATABASE_URL=sqlite:///db.sqlite3 DATABASE_URL=sqlite:///db.sqlite3
```
# Optional: PostgreSQL für bessere Performance **Tipp für SECRET_KEY:**
# DATABASE_URL=postgresql://wrestledesk:dein-passwort@db:5432/wrestledesk ```bash
openssl rand -base64 50
``` ```
### 3. Docker Container starten ### 3. Docker Container starten
@@ -44,23 +47,32 @@ docker-compose up -d --build
Das erste Bauen kann 5-10 Minuten dauern. Das erste Bauen kann 5-10 Minuten dauern.
### 4. Nginx Proxy Manager konfigurieren ### 4. Im Nginx Proxy Manager konfigurieren
1. Öffne `http://deine-unraid-ip:81` 1. Öffne deinen Nginx Proxy Manager (normalerweise `http://deine-unraid-ip:81`)
2. Login: `admin@example.com` / `changeme` (ändern!) 2. **Proxy Hosts****Add Proxy Host**
3. **Proxy Hosts****Add Proxy Host**
**Einstellungen:** **Einstellungen für Frontend:**
- **Domain Names:** `rce.playman.top` - **Domain Names:** `rce.playman.top`
- **Scheme:** `http` - **Scheme:** `http`
- **Forward Hostname / IP:** `frontend` - **Forward Hostname / IP:** `deine-unraid-ip`
- **Forward Port:** `3000` - **Forward Port:** `3000`
- **Block Common Exploits:** ✅ Aktivieren - **Block Common Exploits:** ✅ Aktivieren
**Einstellungen für Backend (API):**
- **Domain Names:** `rce.playman.top`
- **Scheme:** `http`
- **Forward Hostname / IP:** `deine-unraid-ip`
- **Forward Port:** `8000`
- **Advanced Tab** → **Custom Locations**:
- Location: `/api/v1`
- Forward Hostname: `deine-unraid-ip`
- Forward Port: `8000`
**SSL Tab:** **SSL Tab:**
- **SSL Certificate:** `Request a new SSL Certificate` - **SSL Certificate:** `Request a new SSL Certificate`
- **Force SSL:** ✅ Aktivieren - **Force SSL:** ✅ Aktivieren
- **Agree to Terms:** ✅ Aktivieren - **HTTP/2 Support:** ✅ Aktivieren
- **Save** - **Save**
### 5. DNS einrichten ### 5. DNS einrichten
@@ -106,6 +118,9 @@ docker-compose logs -f backend
# Datenbank + Media Backup erstellen # Datenbank + Media Backup erstellen
cd /mnt/user/appdata/wrestledesk cd /mnt/user/appdata/wrestledesk
tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/ tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/
# Backup zu Unraid Array verschieben
mv backup-*.tar.gz /mnt/user/backups/wrestledesk/
``` ```
### Container stoppen ### Container stoppen
@@ -114,54 +129,71 @@ tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/
docker-compose down docker-compose down
``` ```
## Port-Belegung
| Service | Port | Beschreibung |
|---------|------|--------------|
| Frontend | 3000 | Next.js App |
| Backend | 8000 | Django API |
**Wichtig:** Diese Ports müssen von außen NICHT erreichbar sein - nur über Nginx Proxy Manager!
## Fehlerbehebung ## Fehlerbehebung
### Port 80 oder 443 ist belegt ### Frontend zeigt "Backend not reachable"
Prüfe ob die API-URL korrekt ist:
```bash
docker-compose exec frontend env | grep API_URL
# Sollte zeigen: NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
```
### CORS Fehler im Browser
Backend .env prüfen:
```bash
docker-compose exec backend env | grep CORS
# Sollte zeigen: CORS_ALLOWED_ORIGINS=https://rce.playman.top
```
### Container starten nicht
Wenn nginx nicht startet:
```bash ```bash
# Prüfen was den Port blockiert # Prüfen was den Port blockiert
netstat -tlnp | grep :80 netstat -tlnp | grep -E '(:3000|:8000)'
# Oder: Anderen Container stoppen # Oder: Container logs prüfen
docker stop name-des-blockierenden-containers docker-compose logs --tail 50
``` ```
### Let's Encrypt fehlschlägt ### SSL Zertifikat wird nicht erstellt
- Prüfe ob Port 80 von außen erreichbar ist - Prüfe ob Port 80 und 443 von außen erreichbar sind
- Prüfe DNS A-Record - Prüfe DNS A-Record (sollte auf Unraid-IP zeigen)
- Warte 24h nach DNS-Änderungen - Warte 24h nach DNS-Änderungen
- Versuche "Renew" im Nginx Proxy Manager
### Frontend zeigt "Connection refused" ## Sicherheitshinweise
```bash 1. **Ändere das Nginx Proxy Manager Passwort** nach dem ersten Login
# Prüfen ob Backend läuft 2. **Verwende ein starkes SECRET_KEY** in .env (min. 50 Zeichen)
docker-compose ps 3. **Aktiviere "Block Common Exploits"** im Nginx Proxy Manager
4. **Halte Docker Images aktuell:** `docker-compose pull && docker-compose up -d`
# Backend neu starten 5. **Backup regelmäßig durchführen**
docker-compose restart backend
```
## Architektur ## Architektur
``` ```
Internet Internet
Nginx Proxy Manager (Port 443) Nginx Proxy Manager (auf Unraid, zentral)
(Reverse Proxy)
Frontend (Next.js) ←→ Backend (Django) ┌──────────────┐ ┌──────────────┐
↓ ↓ │ Frontend │←──→│ Backend │
Port 3000 Port 8000 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 ## Support
Bei Problemen: Bei Problemen:
+121
View File
@@ -0,0 +1,121 @@
# Nginx Proxy Manager Konfiguration für WrestleDesk
Diese Anleitung beschreibt die Einrichtung von Nginx Proxy Manager, damit das Frontend öffentlich erreichbar ist, das Backend aber intern bleibt.
## Architektur
```
Internet
↓ (HTTPS)
rce.playman.top (Nginx Proxy Manager)
├── / → 192.168.101.42:10001 (Frontend)
└── /api/v1 → 192.168.101.42:10002 (Backend intern)
```
## Schritt 1: Proxy Host für Frontend erstellen
1. Nginx Proxy Manager öffnen (http://deine-unraid-ip:81)
2. **Proxy Hosts****Add Proxy Host**
**Details Tab:**
- **Domain Names:** `rce.playman.top`
- **Scheme:** `http`
- **Forward Hostname / IP:** `192.168.101.42`
- **Forward Port:** `10001`
- **Cache Assets:** ❌ (optional)
- **Block Common Exploits:** ✅ (empfohlen)
**SSL Tab:**
- **SSL Certificate:** `Request a new SSL Certificate`
- **Force SSL:** ✅
- **HTTP/2 Support:** ✅
- **Accept Terms:** ✅
- **Save**
## Schritt 2: API Weiterleitung (Location) hinzufügen
1. Auf den gerade erstellten Host klicken (**Edit**)
2. **Advanced** Tab öffnen
**Custom Locations einfügen:**
```nginx
location /api/v1 {
proxy_pass http://192.168.101.42:10002/api/v1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS Headers für API
add_header 'Access-Control-Allow-Origin' 'https://rce.playman.top' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
# Preflight Requests
if ($request_method = 'OPTIONS') {
return 204;
}
}
```
3. **Save**
## Schritt 3: DNS einrichten
In deinem Domain-Provider:
- **Type:** A
- **Name:** `rce` (oder @ für Root)
- **Value:** `192.168.101.42` (deine Unraid IP)
- **TTL:** 300
## Schritt 4: Testen
Warte 1-2 Minuten für DNS, dann teste:
```bash
# Frontend erreichbar?
curl https://rce.playman.top
# API erreichbar?
curl https://rce.playman.top/api/v1/
# Sollte zurückgeben: {"detail":"Authentication credentials were not provided."}
```
## Sicherheitshinweise
1. **Backend Port 10002** ist nur intern erreichbar (Unraid Firewall)
2. **Niemals** Port 10002 im Router öffnen!
3. Nur Port 80 und 443 (für Nginx Proxy Manager) sollten vom Internet erreichbar sein
## Fehlerbehebung
### "Mixed Content" Fehler im Browser
- Prüfe ob SSL aktiv ist (https://)
- Frontend .env.local muss `https://` enthalten
### CORS Fehler
- Custom Locations müssen korrekt sein
- Backend CORS_ALLOWED_ORIGINS muss `https://rce.playman.top` enthalten
### API nicht erreichbar
```bash
# Teste direkten Backend-Zugriff (nur intern)
curl http://192.168.101.42:10002/api/v1/
# Teste über Proxy
curl https://rce.playman.top/api/v1/
```
## Wichtige Ports
| Port | Service | Öffentlich | Intern |
|------|---------|------------|--------|
| 80 | Nginx HTTP | ✅ | ✅ |
| 443 | Nginx HTTPS | ✅ | ✅ |
| 10001 | Frontend | ❌ | ✅ |
| 10002 | Backend | ❌ | ✅ |
**Backend ist NUR über Nginx Proxy erreichbar, niemals direkt!**
+41
View File
@@ -0,0 +1,41 @@
# Nginx Proxy Manager Konfiguration (Einfach)
## Schritt 1: Proxy Host erstellen
1. Nginx Proxy Manager öffnen (http://deine-unraid-ip:81)
2. **Proxy Hosts****Add Proxy Host**
**Details:**
- Domain Names: `rce.playman.top`
- Scheme: `http`
- Forward Hostname: `192.168.101.42`
- Forward Port: `10001`
- Block Common Exploits: ✅
**SSL:**
- SSL Certificate: Request a new SSL Certificate
- Force SSL: ✅
- HTTP/2 Support: ✅
- Save
## Schritt 2: API Weiterleitung
Auf den Host klicken (Edit) → **Advanced** Tab:
**Custom Locations:**
```nginx
location /api {
proxy_pass http://192.168.101.42:10002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
**Save**
## Fertig!
- Frontend: https://rce.playman.top
- Backend intern: https://rce.playman.top/api/v1
@@ -1,26 +0,0 @@
# 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,
),
),
]
+1 -8
View File
@@ -3,18 +3,11 @@ from django.contrib.auth.models import User
class UserProfile(models.Model): class UserProfile(models.Model):
ROLE_CHOICES = [
('superadmin', 'Super Admin'),
('admin', 'Admin'),
('trainer', 'Trainer'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') 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') 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): def __str__(self):
return f"{self.user.username} Profile ({self.get_role_display()})" return f"{self.user.username} Profile"
class UserPreferences(models.Model): class UserPreferences(models.Model):
-25
View File
@@ -1,25 +0,0 @@
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'
+1 -50
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth.models import User from django.contrib.auth.models import User
from .models import UserPreferences, UserProfile from .models import UserPreferences
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@@ -62,52 +62,3 @@ class UserPreferencesSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = UserPreferences model = UserPreferences
fields = '__all__' 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)
user.profile.role = role
user.profile.save(update_fields=['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)
-15
View File
@@ -1,15 +0,0 @@
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)),
]
+3 -35
View File
@@ -1,18 +1,12 @@
from rest_framework import status, viewsets from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, throttle_classes, action from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth.models import User
from .models import UserPreferences from .models import UserPreferences
from .serializers import ( from .serializers import LoginSerializer, RegisterSerializer, UserSerializer, UserPreferencesSerializer
LoginSerializer, RegisterSerializer, UserSerializer, UserPreferencesSerializer,
UserListSerializer, UserCreateSerializer, UserUpdateSerializer, PasswordChangeSerializer
)
from .permissions import HasUserManagementAccess
from wrestleDesk.pagination import StandardResultsSetPagination
class AuthRateThrottle(AnonRateThrottle): class AuthRateThrottle(AnonRateThrottle):
@@ -102,29 +96,3 @@ def user_preferences(request):
serializer.save() serializer.save()
return Response(serializer.data) return Response(serializer.data)
return Response(serializer.errors, status=400) return Response(serializer.errors, status=400)
class UserManagementViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().select_related('profile')
permission_classes = [HasUserManagementAccess]
pagination_class = StandardResultsSetPagination
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)
+2 -8
View File
@@ -15,7 +15,7 @@ SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG', default=True) DEBUG = env('DEBUG', default=True)
ALLOWED_HOSTS = env('ALLOWED_HOSTS', default='localhost,127.0.0.1,testserver,192.168.101.111').split(',') ALLOWED_HOSTS = env('ALLOWED_HOSTS', default='localhost,127.0.0.1,testserver').split(',')
INSTALLED_APPS = [ INSTALLED_APPS = [
'unfold', 'unfold',
@@ -142,13 +142,7 @@ if CORS_ALLOWED_ORIGINS:
else: else:
if not DEBUG: if not DEBUG:
raise ValueError("CORS_ALLOWED_ORIGINS must be explicitly configured in production") raise ValueError("CORS_ALLOWED_ORIGINS must be explicitly configured in production")
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173']
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://192.168.101.111:3000',
'http://localhost:5173',
'http://127.0.0.1:5173',
]
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
UNFOLD = { UNFOLD = {
+1 -2
View File
@@ -13,7 +13,7 @@ from exercises.views import ExerciseViewSet
from templates.views import TrainingTemplateViewSet, TemplateExerciseViewSet from templates.views import TrainingTemplateViewSet, TemplateExerciseViewSet
from trainings.views import TrainingViewSet, AttendanceViewSet, TrainingExerciseViewSet from trainings.views import TrainingViewSet, AttendanceViewSet, TrainingExerciseViewSet
from homework.views import HomeworkViewSet, HomeworkExerciseItemViewSet, HomeworkAssignmentViewSet, HomeworkStatusViewSet, TrainingHomeworkAssignmentViewSet from homework.views import HomeworkViewSet, HomeworkExerciseItemViewSet, HomeworkAssignmentViewSet, HomeworkStatusViewSet, TrainingHomeworkAssignmentViewSet
from auth_app.views import UserManagementViewSet, login, register, refresh_token, me, user_preferences from auth_app.views import login, register, refresh_token, me, user_preferences
from stats.views import dashboard_stats from stats.views import dashboard_stats
from leistungstest.views import LeistungstestStatsViewSet from leistungstest.views import LeistungstestStatsViewSet
@@ -34,7 +34,6 @@ router.register(r'homework-assignments', HomeworkAssignmentViewSet, basename='ho
router.register(r'homework-status', HomeworkStatusViewSet, basename='homework-status') router.register(r'homework-status', HomeworkStatusViewSet, basename='homework-status')
router.register(r'training-assignments', TrainingHomeworkAssignmentViewSet, basename='training-assignment') router.register(r'training-assignments', TrainingHomeworkAssignmentViewSet, basename='training-assignment')
router.register(r'leistungstest-stats', LeistungstestStatsViewSet, basename='leistungstest-stats') router.register(r'leistungstest-stats', LeistungstestStatsViewSet, basename='leistungstest-stats')
router.register(r'auth/users', UserManagementViewSet, basename='usermanagement')
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
+22 -17
View File
@@ -1,19 +1,36 @@
version: '3.8' version: '3.8'
services: services:
db:
image: postgres:15-alpine
container_name: wrestledesk-db
restart: unless-stopped
volumes:
- ./postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=wrestledesk
- POSTGRES_USER=wrestledesk
- POSTGRES_PASSWORD=${DB_PASSWORD}
networks:
- wrestledesk-network
backend: backend:
build: ./backend build: ./backend
container_name: wrestledesk-backend container_name: wrestledesk-backend
restart: unless-stopped restart: unless-stopped
depends_on:
- db
ports:
- '10002:8000'
volumes: volumes:
- ./backend/media:/app/media - ./backend/media:/app/media
- ./backend/staticfiles:/app/staticfiles - ./backend/staticfiles:/app/staticfiles
environment: environment:
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- DEBUG=False - DEBUG=False
- ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top - ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top,192.168.101.42,nginx-proxy-manager
- CORS_ALLOWED_ORIGINS=https://rce.playman.top - CORS_ALLOWED_ORIGINS=https://rce.playman.top,http://192.168.101.42:10001,http://192.168.101.42:10002
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=postgresql://wrestledesk:${DB_PASSWORD}@db:5432/wrestledesk
networks: networks:
- wrestledesk-network - wrestledesk-network
@@ -21,26 +38,14 @@ services:
build: ./frontend build: ./frontend
container_name: wrestledesk-frontend container_name: wrestledesk-frontend
restart: unless-stopped restart: unless-stopped
ports:
- '10001:3000'
environment: environment:
- NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1 - NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
- NODE_ENV=production - NODE_ENV=production
networks: networks:
- wrestledesk-network - 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: networks:
wrestledesk-network: wrestledesk-network:
driver: bridge driver: bridge
@@ -1,484 +0,0 @@
# 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
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="24" fill="#1B1A55"/>
<text x="96" y="100" font-family="Arial, sans-serif" font-size="80" font-weight="bold" fill="#9290C3" text-anchor="middle">W</text>
</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 (
<html lang="de" suppressHydrationWarning>
<body className={`${syne.variable} ${dmSans.variable} min-h-screen bg-background font-sans antialiased`}>
<Providers>{children}</Providers>
</body>
</html>
)
}
```
- [ ] **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 (
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-card border-t shadow-lg">
<div className="flex items-center justify-between max-w-lg mx-auto">
<div className="flex-1">
<p className="text-sm font-medium">WrestleDesk als App installieren</p>
{isIOS ? (
<p className="text-xs text-muted-foreground">
Tippe auf Teilen "Zum Home Screen hinzufügen"
</p>
) : (
<p className="text-xs text-muted-foreground">
Installieren für schnellen Zugriff
</p>
)}
</div>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)
}
```
- [ ] **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
<InstallPrompt />
</div>
```
- [ ] **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
@@ -1,806 +0,0 @@
# 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<void>
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<ICreateUserInput | IUpdateUserInput>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{mode === 'create' ? 'Neuer Benutzer' : 'Benutzer bearbeiten'}
</DialogTitle>
<DialogDescription>
{mode === 'create'
? 'Erstelle einen neuen Benutzer mit Rolle'
: 'Bearbeite die Benutzerdaten'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Vorname</Label>
<Input
id="firstName"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nachname</Label>
<Input
id="lastName"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
{mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={mode === 'create'}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role">Rolle</Label>
<Select
value={formData.role}
onValueChange={(value) => setFormData({ ...formData, role: value as any })}
>
<SelectTrigger>
<SelectValue placeholder="Rolle auswählen" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
```
- [ ] **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<string, string> = {
superadmin: 'bg-red-100 text-red-800',
admin: 'bg-blue-100 text-blue-800',
trainer: 'bg-green-100 text-green-800',
}
const roleLabels: Record<string, string> = {
superadmin: 'Super Admin',
admin: 'Admin',
trainer: 'Trainer',
}
export default function UsersPage() {
const { token } = useAuth()
const [users, setUsers] = useState<IUser[]>([])
const [loading, setLoading] = useState(true)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingUser, setEditingUser] = useState<IUser | null>(null)
const [formMode, setFormMode] = useState<'create' | 'edit'>('create')
const fetchUsers = async () => {
try {
const data = await apiFetch<IUser[]>('/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<IUser>('/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<IUser>(`/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 <div>Laden...</div>
return (
<FadeIn>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Benutzerverwaltung</CardTitle>
<Button onClick={openCreateForm}>
<Plus className="w-4 h-4 mr-2" />
Neuer Benutzer
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Username</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
{user.first_name} {user.last_name}
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge className={roleColors[user.role] || 'bg-gray-100'}>
{roleLabels[user.role] || user.role}
</Badge>
</TableCell>
<TableCell>
{user.is_active ? (
<span className="text-green-600">Aktiv</span>
) : (
<span className="text-red-600">Inaktiv</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handlePasswordReset(user)}
title="Passwort ändern"
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openEditForm(user)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<UserForm
open={isFormOpen}
onOpenChange={setIsFormOpen}
onSubmit={formMode === 'create' ? handleCreate : handleUpdate}
user={editingUser || undefined}
mode={formMode}
/>
</FadeIn>
)
}
```
- [ ] **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 <dein-token>"
```
- [ ] **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!
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
+16 -35
View File
@@ -2,39 +2,20 @@ const sharp = require('sharp');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const svgBuffer = fs.readFileSync(path.join(__dirname, 'public/icon-192.svg')); const svgPath = path.join(__dirname, 'public/icon-192.svg');
if (!fs.existsSync(svgPath)) {
console.error('SVG template not found at', svgPath);
process.exit(1);
}
const svgBuffer = fs.readFileSync(svgPath);
// icon-192.png Promise.all([
sharp(svgBuffer) sharp(svgBuffer).resize(192, 192).png().toFile(path.join(__dirname, 'public/icon-192.png')),
.resize(192, 192) sharp(svgBuffer).resize(512, 512).png().toFile(path.join(__dirname, 'public/icon-512.png')),
.png() sharp(svgBuffer).resize(180, 180).png().toFile(path.join(__dirname, 'public/apple-touch-icon.png')),
.toFile('public/icon-192.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(path.join(__dirname, 'public/icon-maskable.png'))
.then(() => console.log('Created icon-192.png')); ]).then(() => {
console.log('Icons created successfully');
// icon-512.png }).catch((err) => {
sharp(svgBuffer) console.error('Icon generation failed:', err);
.resize(512, 512) });
.png()
.toFile('public/icon-512.png')
.then(() => console.log('Created icon-512.png'));
// apple-touch-icon.png (180x180)
sharp(svgBuffer)
.resize(180, 180)
.png()
.toFile('public/apple-touch-icon.png')
.then(() => console.log('Created apple-touch-icon.png'));
// icon-maskable.png (512x512 with padding for safe area)
sharp(svgBuffer)
.resize(384, 384) // 75% of 512 for safe area
.extend({
top: 64,
bottom: 64,
left: 64,
right: 64,
background: { r: 27, g: 26, b: 85, alpha: 1 } // #1B1A55
})
.png()
.toFile('public/icon-maskable.png')
.then(() => console.log('Created icon-maskable.png'));
+1 -5
View File
@@ -23,6 +23,7 @@
"react-big-calendar": "^1.19.4", "react-big-calendar": "^1.19.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -37,7 +38,6 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"sharp": "^0.34.5",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
@@ -1055,7 +1055,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -4193,7 +4192,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -8786,7 +8784,6 @@
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -8831,7 +8828,6 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
+2 -1
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"dev:host": "next dev --hostname 192.168.101.111",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
@@ -24,6 +25,7 @@
"react-big-calendar": "^1.19.4", "react-big-calendar": "^1.19.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -38,7 +40,6 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"sharp": "^0.34.5",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
-55
View File
@@ -1,55 +0,0 @@
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('<h1>404 Not Found</h1>');
} 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}`);
});
@@ -1,214 +0,0 @@
"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/ui/animations"
const roleColors: Record<string, string> = {
superadmin: 'bg-red-100 text-red-800',
admin: 'bg-blue-100 text-blue-800',
trainer: 'bg-green-100 text-green-800',
}
const roleLabels: Record<string, string> = {
superadmin: 'Super Admin',
admin: 'Admin',
trainer: 'Trainer',
}
export default function UsersPage() {
const { token } = useAuth()
const [users, setUsers] = useState<IUser[]>([])
const [loading, setLoading] = useState(true)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingUser, setEditingUser] = useState<IUser | null>(null)
const [formMode, setFormMode] = useState<'create' | 'edit'>('create')
const fetchUsers = async () => {
try {
const data = await apiFetch<{ results: IUser[] }>('/auth/users/', { token: token! })
setUsers(data.results || [])
} catch {
toast.error('Fehler beim Laden der Benutzer')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [token])
const handleCreate = async (data: ICreateUserInput) => {
try {
await apiFetch<IUser>('/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<IUser>(`/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 <div>Laden...</div>
return (
<FadeIn>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Benutzerverwaltung</CardTitle>
<Button onClick={openCreateForm}>
<Plus className="w-4 h-4 mr-2" />
Neuer Benutzer
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Username</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
{user.first_name} {user.last_name}
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge className={roleColors[user.role || 'trainer'] || 'bg-gray-100'}>
{roleLabels[user.role || 'trainer'] || user.role}
</Badge>
</TableCell>
<TableCell>
{user.is_active ? (
<span className="text-green-600">Aktiv</span>
) : (
<span className="text-red-600">Inaktiv</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handlePasswordReset(user)}
title="Passwort ändern"
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openEditForm(user)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id!)}
className="text-red-600"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<UserForm
open={isFormOpen}
onOpenChange={setIsFormOpen}
onSubmit={formMode === 'create' ? ((data: ICreateUserInput) => handleCreate(data)) : ((data: IUpdateUserInput) => handleUpdate(data))}
user={editingUser || undefined}
mode={formMode}
/>
</FadeIn>
)
}
+37 -68
View File
@@ -126,80 +126,49 @@
} }
html { html {
@apply font-sans; @apply font-sans;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 20%); background: oklch(0.5 0 0 / 20%);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0 / 35%); background: oklch(0.5 0 0 / 35%);
} }
}
/* Mobile/PWA Optimierungen */ /* Mobile/PWA Optimizations */
html { html { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
-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); }
/* iOS Safe Areas */ /* Prevent zoom on input focus */
body { input, select, textarea { font-size: 16px; }
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom); /* Hide scrollbar on mobile */
padding-left: env(safe-area-inset-left); @media (max-width: 768px) {
padding-right: env(safe-area-inset-right); ::-webkit-scrollbar { display: none; }
} body { scrollbar-width: none; }
}
/* Kein Zoom bei Input-Fokus auf iOS */
input, select, textarea { /* Minimum touch target 44x44px */
font-size: 16px; button, a, input, select, textarea, [role="button"] { min-height: 44px; min-width: 44px; }
}
/* Disable hover effects on touch devices */
/* Scrollbar auf Mobile ausblenden */ @media (hover: none) { *:hover { transform: none !important; } }
@media (max-width: 768px) {
::-webkit-scrollbar { /* PWA standalone mode styles */
display: none; @media (display-mode: standalone) {
} html { height: 100vh; height: 100dvh; }
body { body { overflow: hidden; position: fixed; width: 100%; height: 100%; }
scrollbar-width: none;
}
}
/* Minimum Touch Target 44x44px */
button, a, input, select, textarea, [role="button"] {
min-height: 44px;
min-width: 44px;
}
/* Kein Hover-Effekt auf Touch-Geräten */
@media (hover: none) {
*:hover {
-webkit-transform: none !important;
transform: none !important;
}
}
/* PWA App-Look im Standalone-Modus */
@media (display-mode: standalone) {
html {
height: 100vh;
height: 100dvh;
}
body {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
}
} }
+2 -4
View File
@@ -7,17 +7,15 @@ import { Loader2 } from "lucide-react"
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
const { token, isHydrated } = useAuth() const { token } = useAuth()
useEffect(() => { useEffect(() => {
if (!isHydrated) return
if (token) { if (token) {
router.push("/dashboard") router.push("/dashboard")
} else { } else {
router.push("/login") router.push("/login")
} }
}, [token, isHydrated, router]) }, [token, router])
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@@ -100,17 +100,6 @@ export function Sidebar() {
Einstellungen Einstellungen
</motion.div> </motion.div>
</Link> </Link>
<Link href="/settings/users" className="block mb-2">
<motion.div
className="flex items-center gap-3 px-3 pl-8 py-1 rounded-lg text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
whileHover={{ x: 2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
>
<Users className="w-4 h-4" />
Benutzer
</motion.div>
</Link>
<div className="flex items-center justify-between px-3 py-2"> <div className="flex items-center justify-between px-3 py-2">
<motion.span <motion.span
className="text-sm text-sidebar-foreground" className="text-sm text-sidebar-foreground"
+7 -15
View File
@@ -10,20 +10,16 @@ export function InstallPrompt() {
const [isStandalone, setIsStandalone] = useState(false) const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => { useEffect(() => {
// Prüfe ob bereits als PWA installiert
const standalone = window.matchMedia('(display-mode: standalone)').matches || const standalone = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone || (window.navigator as any).standalone ||
document.referrer.includes('android-app://') document.referrer.includes('android-app://')
setIsStandalone(standalone) setIsStandalone(standalone)
// Prüfe iOS
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
setIsIOS(isIOSDevice) setIsIOS(isIOSDevice)
// Zeige Prompt nur wenn nicht bereits installiert und nicht bereits geschlossen
const dismissed = localStorage.getItem('install-prompt-dismissed') const dismissed = localStorage.getItem('install-prompt-dismissed')
if (!standalone && !dismissed) { if (!standalone && !dismissed) {
// Verzögert anzeigen
setTimeout(() => setShow(true), 3000) setTimeout(() => setShow(true), 3000)
} }
}, []) }, [])
@@ -36,27 +32,23 @@ export function InstallPrompt() {
if (!show || isStandalone) return null if (!show || isStandalone) return null
return ( return (
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-card border-t shadow-lg safe-area-bottom"> <div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-card border-t shadow-lg">
<div className="flex items-center justify-between max-w-lg mx-auto"> <div className="flex items-center justify-between max-w-lg mx-auto">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium"> <p className="text-sm font-medium">WrestleDesk als App installieren</p>
WrestleDesk als App installieren
</p>
{isIOS ? ( {isIOS ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground">
Tippe auf Teilen "Zum Home Screen hinzufügen" Tippe auf Teilen "Zum Home Screen hinzufügen"
</p> </p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground">
Installieren für schnellen Zugriff Installieren für schnellen Zugriff
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <Button size="sm" variant="ghost" onClick={handleDismiss}>
<Button size="sm" variant="ghost" onClick={handleDismiss}> <X className="w-4 h-4" />
<X className="w-4 h-4" /> </Button>
</Button>
</div>
</div> </div>
</div> </div>
) )
-161
View File
@@ -1,161 +0,0 @@
"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 { IUser } from "@/lib/api"
interface UserFormProps {
open: boolean
onOpenChange: (open: boolean) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSubmit: (data: any) => Promise<void>
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 || 'trainer',
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await onSubmit(formData)
onOpenChange(false)
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{mode === 'create' ? 'Neuer Benutzer' : 'Benutzer bearbeiten'}
</DialogTitle>
<DialogDescription>
{mode === 'create'
? 'Erstelle einen neuen Benutzer mit Rolle'
: 'Bearbeite die Benutzerdaten'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Vorname</Label>
<Input
id="firstName"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nachname</Label>
<Input
id="lastName"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
{mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={mode === 'create'}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role">Rolle</Label>
<Select
value={formData.role}
onValueChange={(value) => setFormData({ ...formData, role: value || 'trainer' })}
>
<SelectTrigger>
<SelectValue placeholder="Rolle auswählen" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
-21
View File
@@ -265,27 +265,6 @@ export interface IUser {
last_name?: string last_name?: string
club_id?: number | null club_id?: number | null
club_name?: string | 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 { export interface IAuthResponse {
+1 -5
View File
@@ -15,7 +15,6 @@ interface AuthState {
logout: () => void logout: () => void
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<void>
checkAuth: () => Promise<void> checkAuth: () => Promise<void>
setHydrated: () => void
} }
export const useAuth = create<AuthState>()( export const useAuth = create<AuthState>()(
@@ -24,12 +23,10 @@ export const useAuth = create<AuthState>()(
token: null, token: null,
refreshToken: null, refreshToken: null,
user: null, user: null,
isLoading: true, isLoading: false,
isHydrated: false, isHydrated: false,
error: null, error: null,
setHydrated: () => set({ isHydrated: true }),
setAuth: (token, refreshToken, user) => { setAuth: (token, refreshToken, user) => {
set({ token, refreshToken, user, error: null }) set({ token, refreshToken, user, error: null })
}, },
@@ -104,7 +101,6 @@ export const useAuth = create<AuthState>()(
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
if (state) { if (state) {
state.isHydrated = true state.isHydrated = true
state.isLoading = false
} }
}, },
} }