Compare commits

..

5 Commits

Author SHA1 Message Date
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
35 changed files with 60 additions and 2416 deletions
-6
View File
@@ -1,6 +0,0 @@
# Backend Environment
SECRET_KEY=change-me-to-a-long-random-string-in-production
DATABASE_URL=sqlite:///db.sqlite3
# For PostgreSQL (recommended for production):
# DATABASE_URL=postgresql://user:password@db:5432/wrestledesk
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)
-170
View File
@@ -1,170 +0,0 @@
# Docker Deployment Guide
Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Server mit Docker Compose und Nginx Proxy Manager.
## Voraussetzungen
- Unraid Server mit Docker Compose Plugin
- Domain (z.B. rce.playman.top) mit DNS A-Record auf Unraid-IP
- Ports 80, 443 und 81 freigegeben
## Schnellstart
### 1. Repository klonen
```bash
cd /mnt/user/appdata/
mkdir wrestledesk && cd wrestledesk
git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git .
```
### 2. Umgebungsvariablen konfigurieren
```bash
cp .env.example .env
nano .env # Oder dein bevorzugter Editor
```
**Wichtige Werte in .env ändern:**
```env
# Backend
SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen
DATABASE_URL=sqlite:///db.sqlite3
# Optional: PostgreSQL für bessere Performance
# DATABASE_URL=postgresql://wrestledesk:dein-passwort@db:5432/wrestledesk
```
### 3. Docker Container starten
```bash
docker-compose up -d --build
```
Das erste Bauen kann 5-10 Minuten dauern.
### 4. Nginx Proxy Manager konfigurieren
1. Öffne `http://deine-unraid-ip:81`
2. Login: `admin@example.com` / `changeme` (ändern!)
3. **Proxy Hosts****Add Proxy Host**
**Einstellungen:**
- **Domain Names:** `rce.playman.top`
- **Scheme:** `http`
- **Forward Hostname / IP:** `frontend`
- **Forward Port:** `3000`
- **Block Common Exploits:** ✅ Aktivieren
**SSL Tab:**
- **SSL Certificate:** `Request a new SSL Certificate`
- **Force SSL:** ✅ Aktivieren
- **Agree to Terms:** ✅ Aktivieren
- **Save**
### 5. DNS einrichten
In deinem Domain-Provider:
- **Type:** A
- **Name:** rce (oder @ für Root)
- **Value:** Deine Unraid Server IP
- **TTL:** 300
### 6. Fertig!
Nach wenigen Minuten sollte WrestleDesk erreichbar sein unter:
- **https://rce.playman.top**
## Wartung
### Updates durchführen
```bash
cd /mnt/user/appdata/wrestledesk
git pull origin feature/pwa # Oder main
docker-compose down
docker-compose up -d --build
```
### Logs ansehen
```bash
# Alle Services
docker-compose logs -f
# Nur Frontend
docker-compose logs -f frontend
# Nur Backend
docker-compose logs -f backend
```
### Backup
```bash
# Datenbank + Media Backup erstellen
cd /mnt/user/appdata/wrestledesk
tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/
```
### Container stoppen
```bash
docker-compose down
```
## Fehlerbehebung
### Port 80 oder 443 ist belegt
Wenn nginx nicht startet:
```bash
# Prüfen was den Port blockiert
netstat -tlnp | grep :80
# Oder: Anderen Container stoppen
docker stop name-des-blockierenden-containers
```
### Let's Encrypt fehlschlägt
- Prüfe ob Port 80 von außen erreichbar ist
- Prüfe DNS A-Record
- Warte 24h nach DNS-Änderungen
### Frontend zeigt "Connection refused"
```bash
# Prüfen ob Backend läuft
docker-compose ps
# Backend neu starten
docker-compose restart backend
```
## Architektur
```
Internet
Nginx Proxy Manager (Port 443)
Frontend (Next.js) ←→ Backend (Django)
↓ ↓
Port 3000 Port 8000
```
## Sicherheitshinweise
1. **Ändere das Nginx Proxy Manager Passwort** nach dem ersten Login
2. **Verwende ein starkes SECRET_KEY** in .env
3. **Aktiviere "Block Common Exploits"** in Nginx Proxy Manager
4. **Halte Docker Images aktuell:** `docker-compose pull && docker-compose up -d`
## Support
Bei Problemen:
1. Logs prüfen: `docker-compose logs`
2. Container Status: `docker-compose ps`
3. Netzwerk prüfen: `docker network ls`
-23
View File
@@ -1,23 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/media /app/staticfiles
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "60", "wrestleDesk.wsgi:application"]
@@ -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),
-46
View File
@@ -1,46 +0,0 @@
version: '3.8'
services:
backend:
build: ./backend
container_name: wrestledesk-backend
restart: unless-stopped
volumes:
- ./backend/media:/app/media
- ./backend/staticfiles:/app/staticfiles
environment:
- SECRET_KEY=${SECRET_KEY}
- DEBUG=False
- ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top
- CORS_ALLOWED_ORIGINS=https://rce.playman.top
- DATABASE_URL=${DATABASE_URL}
networks:
- wrestledesk-network
frontend:
build: ./frontend
container_name: wrestledesk-frontend
restart: unless-stopped
environment:
- NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
- NODE_ENV=production
networks:
- wrestledesk-network
nginx:
image: 'jc21/nginx-proxy-manager:latest'
container_name: wrestledesk-nginx
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '81:81'
volumes:
- ./nginx-data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- wrestledesk-network
networks:
wrestledesk-network:
driver: bridge
@@ -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!
-26
View File
@@ -1,26 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+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 -4
View File
@@ -1,10 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', /* config options here */
images: {
unoptimized: true,
},
}; };
export default nextConfig; export default nextConfig;
+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"
+1 -1
View File
@@ -24,6 +24,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 +39,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"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

-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}`);
});
-2
View File
@@ -5,7 +5,6 @@ import { useRouter, usePathname } from "next/navigation"
import { useAuth } from "@/lib/auth" import { useAuth } from "@/lib/auth"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { Sidebar } from "@/components/layout/Sidebar" import { Sidebar } from "@/components/layout/Sidebar"
import { InstallPrompt } from "@/components/ui/install-prompt"
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@@ -47,7 +46,6 @@ export default function DashboardLayout({
{children} {children}
</div> </div>
</main> </main>
<InstallPrompt />
</div> </div>
) )
} }
@@ -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>
)
}
+30 -61
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 Optimizations */
html { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
/* Mobile/PWA Optimierungen */ /* iOS Safe Areas */
html { 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); }
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
/* 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);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* Kein Zoom bei Input-Fokus auf iOS */ /* Hide scrollbar on mobile */
input, select, textarea { @media (max-width: 768px) {
font-size: 16px; ::-webkit-scrollbar { display: none; }
} body { scrollbar-width: none; }
}
/* Scrollbar auf Mobile ausblenden */ /* Minimum touch target 44x44px */
@media (max-width: 768px) { button, a, input, select, textarea, [role="button"] { min-height: 44px; min-width: 44px; }
::-webkit-scrollbar {
display: none;
}
body {
scrollbar-width: none;
}
}
/* Minimum Touch Target 44x44px */ /* Disable hover effects on touch devices */
button, a, input, select, textarea, [role="button"] { @media (hover: none) { *:hover { transform: none !important; } }
min-height: 44px;
min-width: 44px;
}
/* Kein Hover-Effekt auf Touch-Geräten */ /* PWA standalone mode styles */
@media (hover: none) { @media (display-mode: standalone) {
*:hover { html { height: 100vh; height: 100dvh; }
-webkit-transform: none !important; body { overflow: hidden; position: fixed; width: 100%; height: 100%; }
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"
@@ -1,63 +0,0 @@
"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(() => {
// 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)
}
}, [])
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 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>
{isIOS ? (
<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 mt-0.5">
Installieren für schnellen Zugriff
</p>
)}
</div>
<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
@@ -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
} }
}, },
} }