Compare commits

9 Commits

Author SHA1 Message Date
Andrej Spielmann b3a005f3bf fix(user-management): pagination, duplicate profile creation, type fixes 2026-03-26 17:03:38 +01:00
Andrej Spielmann 9c8acfd30e fix(users): resolve import path and type errors 2026-03-26 16:53:02 +01:00
Andrej Spielmann a0ec4829b1 feat: complete user management system
- Add UserProfile role field (superadmin/admin/trainer)
- Create role-based permission classes
- Implement UserManagementViewSet with CRUD
- Add frontend types and components
- Create users management page
- Add sidebar navigation for users
- Only superadmins can manage users
2026-03-26 16:45:25 +01:00
Andrej Spielmann 28222d634d feat: implement user management system
- Add role field to UserProfile (superadmin/admin/trainer)
- Add role-based permission classes
- Create UserManagementViewSet with CRUD and password change
- Add API types and components for user management
- Create users management page in settings
- Only superadmins can manage users
2026-03-26 16:42:08 +01:00
Andrej Spielmann 7611533718 docs: add PWA implementation guide and git workflow notes
- Document PWA feature with testing instructions
- Add Git workflow: push after every task completion
- Document iPhone testing steps
2026-03-26 15:15:48 +01:00
Andrej Spielmann f7bd930ad7 Fix isHydrated state for Zustand persist
- Add onRehydrateStorage callback to set isHydrated
- Initialize isLoading to true and set to false after hydration
- Remove manual isHydrated check in page.tsx
2026-03-26 13:53:46 +01:00
Andrej Spielmann e42f5e2315 Fix network access for iPhone testing
- Add 192.168.101.111 to ALLOWED_HOSTS and CORS_ALLOWED_ORIGINS
- Update frontend env to use network IP for API
- Fix page redirect to wait for hydration
2026-03-26 13:52:24 +01:00
Andrej Spielmann b571509d41 Add generated PNG icons and icon generator script
- icon-192.png
- icon-512.png
- apple-touch-icon.png
- icon-maskable.png
- generate-icons.js script
2026-03-26 13:43:36 +01:00
Andrej Spielmann 824191ce81 Add PWA support and mobile optimizations
- Add manifest.json with PWA configuration
- Add viewport settings for iOS (viewport-fit: cover)
- Add meta tags for iOS Safari (apple-mobile-web-app-capable)
- Add mobile CSS optimizations:
  * iOS Safe Area support
  * Minimum 44x44px touch targets
  * Disable zoom on input focus
  * Remove scrollbars on mobile
  * Disable hover effects on touch devices
  * Standalone mode styles
- Add InstallPrompt component for Add to Home Screen
- Add SVG icon (needs PNG conversion)
2026-03-26 13:40:54 +01:00
30 changed files with 2151 additions and 327 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Backend Environment
SECRET_KEY=change-me-to-a-long-random-string-in-production
DB_PASSWORD=change-me-to-a-secure-password
DATABASE_URL=sqlite:///db.sqlite3
# Optional: PostgreSQL (empfohlen für production)
# DATABASE_URL wird automatisch gesetzt: postgresql://wrestledesk:DB_PASSWORD@db:5432/wrestledesk
# For PostgreSQL (recommended for production):
# DATABASE_URL=postgresql://user:password@db:5432/wrestledesk
Submodule .worktrees/pwa-implementation added at 11d9267b2f
+43
View File
@@ -1181,6 +1181,49 @@ 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
- Rate limiting on auth endpoints (5/minute)
+40 -72
View File
@@ -1,12 +1,12 @@
# Docker Deployment Guide für Unraid
# Docker Deployment Guide
Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Server mit Docker Compose. **Nginx Proxy Manager läuft bereits zentral.**
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
- Nginx Proxy Manager läuft bereits (Port 81 für Admin-UI)
- Ports 80, 443 und 81 freigegeben
## Schnellstart
@@ -16,7 +16,6 @@ Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Serve
cd /mnt/user/appdata/
mkdir wrestledesk && cd wrestledesk
git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git .
git checkout feature/pwa # Wichtig: PWA Branch verwenden
```
### 2. Umgebungsvariablen konfigurieren
@@ -32,11 +31,9 @@ nano .env # Oder dein bevorzugter Editor
# Backend
SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen
DATABASE_URL=sqlite:///db.sqlite3
```
**Tipp für SECRET_KEY:**
```bash
openssl rand -base64 50
# Optional: PostgreSQL für bessere Performance
# DATABASE_URL=postgresql://wrestledesk:dein-passwort@db:5432/wrestledesk
```
### 3. Docker Container starten
@@ -47,32 +44,23 @@ docker-compose up -d --build
Das erste Bauen kann 5-10 Minuten dauern.
### 4. Im Nginx Proxy Manager konfigurieren
### 4. Nginx Proxy Manager konfigurieren
1. Öffne deinen Nginx Proxy Manager (normalerweise `http://deine-unraid-ip:81`)
2. **Proxy Hosts****Add Proxy Host**
1. Öffne `http://deine-unraid-ip:81`
2. Login: `admin@example.com` / `changeme` (ändern!)
3. **Proxy Hosts****Add Proxy Host**
**Einstellungen für Frontend:**
**Einstellungen:**
- **Domain Names:** `rce.playman.top`
- **Scheme:** `http`
- **Forward Hostname / IP:** `deine-unraid-ip`
- **Forward Hostname / IP:** `frontend`
- **Forward Port:** `3000`
- **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 Certificate:** `Request a new SSL Certificate`
- **Force SSL:** ✅ Aktivieren
- **HTTP/2 Support:** ✅ Aktivieren
- **Agree to Terms:** ✅ Aktivieren
- **Save**
### 5. DNS einrichten
@@ -118,9 +106,6 @@ docker-compose logs -f backend
# Datenbank + Media Backup erstellen
cd /mnt/user/appdata/wrestledesk
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
@@ -129,71 +114,54 @@ mv backup-*.tar.gz /mnt/user/backups/wrestledesk/
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
### 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
### Port 80 oder 443 ist belegt
Wenn nginx nicht startet:
```bash
# Prüfen was den Port blockiert
netstat -tlnp | grep -E '(:3000|:8000)'
netstat -tlnp | grep :80
# Oder: Container logs prüfen
docker-compose logs --tail 50
# Oder: Anderen Container stoppen
docker stop name-des-blockierenden-containers
```
### SSL Zertifikat wird nicht erstellt
### Let's Encrypt fehlschlägt
- Prüfe ob Port 80 und 443 von außen erreichbar sind
- Prüfe DNS A-Record (sollte auf Unraid-IP zeigen)
- Prüfe ob Port 80 von außen erreichbar ist
- Prüfe DNS A-Record
- Warte 24h nach DNS-Änderungen
- Versuche "Renew" im Nginx Proxy Manager
## Sicherheitshinweise
### Frontend zeigt "Connection refused"
1. **Ändere das Nginx Proxy Manager Passwort** nach dem ersten Login
2. **Verwende ein starkes SECRET_KEY** in .env (min. 50 Zeichen)
3. **Aktiviere "Block Common Exploits"** im Nginx Proxy Manager
4. **Halte Docker Images aktuell:** `docker-compose pull && docker-compose up -d`
5. **Backup regelmäßig durchführen**
```bash
# Prüfen ob Backend läuft
docker-compose ps
# Backend neu starten
docker-compose restart backend
```
## Architektur
```
Internet
Nginx Proxy Manager (auf Unraid, zentral)
(Reverse Proxy)
┌──────────────┐ ┌──────────────┐
│ Frontend │←──→│ Backend │
Port 3000 Port 8000
└──────────────┘ └──────────────┘
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:
-121
View File
@@ -1,121 +0,0 @@
# 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
@@ -1,41 +0,0 @@
# 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
@@ -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,
),
),
]
+8 -1
View File
@@ -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):
+25
View File
@@ -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'
+50 -1
View File
@@ -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,52 @@ 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)
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
@@ -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)),
]
+35 -3
View File
@@ -1,12 +1,18 @@
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
from wrestleDesk.pagination import StandardResultsSetPagination
class AuthRateThrottle(AnonRateThrottle):
@@ -96,3 +102,29 @@ 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]
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)
+8 -2
View File
@@ -15,7 +15,7 @@ SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG', default=True)
ALLOWED_HOSTS = env('ALLOWED_HOSTS', default='localhost,127.0.0.1,testserver').split(',')
ALLOWED_HOSTS = env('ALLOWED_HOSTS', default='localhost,127.0.0.1,testserver,192.168.101.111').split(',')
INSTALLED_APPS = [
'unfold',
@@ -142,7 +142,13 @@ if CORS_ALLOWED_ORIGINS:
else:
if not DEBUG:
raise ValueError("CORS_ALLOWED_ORIGINS must be explicitly configured in production")
CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173']
CORS_ALLOWED_ORIGINS = [
'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
UNFOLD = {
+2 -1
View File
@@ -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),
+17 -22
View File
@@ -1,36 +1,19 @@
version: '3.8'
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:
build: ./backend
container_name: wrestledesk-backend
restart: unless-stopped
depends_on:
- db
ports:
- '10002:8000'
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,192.168.101.42,nginx-proxy-manager
- CORS_ALLOWED_ORIGINS=https://rce.playman.top,http://192.168.101.42:10001,http://192.168.101.42:10002
- DATABASE_URL=postgresql://wrestledesk:${DB_PASSWORD}@db:5432/wrestledesk
- ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top
- CORS_ALLOWED_ORIGINS=https://rce.playman.top
- DATABASE_URL=${DATABASE_URL}
networks:
- wrestledesk-network
@@ -38,14 +21,26 @@ services:
build: ./frontend
container_name: wrestledesk-frontend
restart: unless-stopped
ports:
- '10001:3000'
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
@@ -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
<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
@@ -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<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
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
+35 -16
View File
@@ -2,20 +2,39 @@ const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
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);
const svgBuffer = fs.readFileSync(path.join(__dirname, 'public/icon-192.svg'));
Promise.all([
sharp(svgBuffer).resize(192, 192).png().toFile(path.join(__dirname, 'public/icon-192.png')),
sharp(svgBuffer).resize(512, 512).png().toFile(path.join(__dirname, 'public/icon-512.png')),
sharp(svgBuffer).resize(180, 180).png().toFile(path.join(__dirname, 'public/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(path.join(__dirname, 'public/icon-maskable.png'))
]).then(() => {
console.log('Icons created successfully');
}).catch((err) => {
console.error('Icon generation failed:', err);
});
// icon-192.png
sharp(svgBuffer)
.resize(192, 192)
.png()
.toFile('public/icon-192.png')
.then(() => console.log('Created icon-192.png'));
// icon-512.png
sharp(svgBuffer)
.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'));
+5 -1
View File
@@ -23,7 +23,6 @@
"react-big-calendar": "^1.19.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.0",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -38,6 +37,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"sharp": "^0.34.5",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -1055,6 +1055,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4192,6 +4193,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -8784,6 +8786,7 @@
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -8828,6 +8831,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+1 -2
View File
@@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "next dev",
"dev:host": "next dev --hostname 192.168.101.111",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -25,7 +24,6 @@
"react-big-calendar": "^1.19.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.0",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -40,6 +38,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"sharp": "^0.34.5",
"tailwindcss": "^4",
"typescript": "^5"
}
+55
View File
@@ -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('<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}`);
});
@@ -0,0 +1,214 @@
"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>
)
}
+61 -30
View File
@@ -126,49 +126,80 @@
}
html {
@apply font-sans;
}
}
::-webkit-scrollbar {
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
}
::-webkit-scrollbar-track {
::-webkit-scrollbar-track {
background: transparent;
}
}
::-webkit-scrollbar-thumb {
::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 20%);
border-radius: 3px;
}
}
::-webkit-scrollbar-thumb:hover {
::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0 / 35%);
}
}
/* 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); }
/* Mobile/PWA Optimierungen */
html {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
/* Prevent zoom on input focus */
input, select, textarea { font-size: 16px; }
/* 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);
}
/* Hide scrollbar on mobile */
@media (max-width: 768px) {
::-webkit-scrollbar { display: none; }
body { scrollbar-width: none; }
}
/* Kein Zoom bei Input-Fokus auf iOS */
input, select, textarea {
font-size: 16px;
}
/* Minimum touch target 44x44px */
button, a, input, select, textarea, [role="button"] { min-height: 44px; min-width: 44px; }
/* Scrollbar auf Mobile ausblenden */
@media (max-width: 768px) {
::-webkit-scrollbar {
display: none;
}
body {
scrollbar-width: none;
}
}
/* Disable hover effects on touch devices */
@media (hover: none) { *:hover { transform: none !important; } }
/* Minimum Touch Target 44x44px */
button, a, input, select, textarea, [role="button"] {
min-height: 44px;
min-width: 44px;
}
/* PWA standalone mode styles */
@media (display-mode: standalone) {
html { height: 100vh; height: 100dvh; }
body { overflow: hidden; position: fixed; width: 100%; height: 100%; }
}
/* 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%;
}
}
}
+4 -2
View File
@@ -7,15 +7,17 @@ import { Loader2 } from "lucide-react"
export default function HomePage() {
const router = useRouter()
const { token } = useAuth()
const { token, isHydrated } = useAuth()
useEffect(() => {
if (!isHydrated) return
if (token) {
router.push("/dashboard")
} else {
router.push("/login")
}
}, [token, router])
}, [token, isHydrated, router])
return (
<div className="min-h-screen flex items-center justify-center">
@@ -100,6 +100,17 @@ export function Sidebar() {
Einstellungen
</motion.div>
</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">
<motion.span
className="text-sm text-sidebar-foreground"
+15 -7
View File
@@ -10,16 +10,20 @@ export function InstallPrompt() {
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
// Prüfe ob bereits als PWA installiert
const standalone = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone ||
document.referrer.includes('android-app://')
setIsStandalone(standalone)
// Prüfe iOS
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
setIsIOS(isIOSDevice)
// Zeige Prompt nur wenn nicht bereits installiert und nicht bereits geschlossen
const dismissed = localStorage.getItem('install-prompt-dismissed')
if (!standalone && !dismissed) {
// Verzögert anzeigen
setTimeout(() => setShow(true), 3000)
}
}, [])
@@ -32,23 +36,27 @@ export function InstallPrompt() {
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="fixed bottom-0 left-0 right-0 z-50 p-4 bg-card border-t shadow-lg safe-area-bottom">
<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>
<p className="text-sm font-medium">
WrestleDesk als App installieren
</p>
{isIOS ? (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground mt-0.5">
Tippe auf Teilen "Zum Home Screen hinzufügen"
</p>
) : (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground mt-0.5">
Installieren für schnellen Zugriff
</p>
)}
</div>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
<X className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={handleDismiss}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)
+161
View File
@@ -0,0 +1,161 @@
"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,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 {
+5 -1
View File
@@ -15,6 +15,7 @@ interface AuthState {
logout: () => void
login: (username: string, password: string) => Promise<void>
checkAuth: () => Promise<void>
setHydrated: () => void
}
export const useAuth = create<AuthState>()(
@@ -23,10 +24,12 @@ export const useAuth = create<AuthState>()(
token: null,
refreshToken: null,
user: null,
isLoading: false,
isLoading: true,
isHydrated: false,
error: null,
setHydrated: () => set({ isHydrated: true }),
setAuth: (token, refreshToken, user) => {
set({ token, refreshToken, user, error: null })
},
@@ -101,6 +104,7 @@ export const useAuth = create<AuthState>()(
onRehydrateStorage: () => (state) => {
if (state) {
state.isHydrated = true
state.isLoading = false
}
},
}