Compare commits
9 Commits
feature/pwa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a005f3bf | |||
| 9c8acfd30e | |||
| a0ec4829b1 | |||
| 28222d634d | |||
| 7611533718 | |||
| f7bd930ad7 | |||
| e42f5e2315 | |||
| b571509d41 | |||
| 824191ce81 |
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# Backend Environment
|
# Backend Environment
|
||||||
SECRET_KEY=change-me-to-a-long-random-string-in-production
|
SECRET_KEY=change-me-to-a-long-random-string-in-production
|
||||||
DB_PASSWORD=change-me-to-a-secure-password
|
DATABASE_URL=sqlite:///db.sqlite3
|
||||||
|
|
||||||
# Optional: PostgreSQL (empfohlen für production)
|
# For PostgreSQL (recommended for production):
|
||||||
# DATABASE_URL wird automatisch gesetzt: postgresql://wrestledesk:DB_PASSWORD@db:5432/wrestledesk
|
# DATABASE_URL=postgresql://user:password@db:5432/wrestledesk
|
||||||
|
|||||||
Submodule
+1
Submodule .worktrees/pwa-implementation added at 11d9267b2f
@@ -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
|
## Security Notes
|
||||||
|
|
||||||
- Rate limiting on auth endpoints (5/minute)
|
- Rate limiting on auth endpoints (5/minute)
|
||||||
|
|||||||
+40
-72
@@ -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
|
## Voraussetzungen
|
||||||
|
|
||||||
- Unraid Server mit Docker Compose Plugin
|
- Unraid Server mit Docker Compose Plugin
|
||||||
- Domain (z.B. rce.playman.top) mit DNS A-Record auf Unraid-IP
|
- Domain (z.B. rce.playman.top) mit DNS A-Record auf Unraid-IP
|
||||||
- Nginx Proxy Manager läuft bereits (Port 81 für Admin-UI)
|
- Ports 80, 443 und 81 freigegeben
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ Diese Anleitung beschreibt die Deployment von WrestleDesk auf einem Unraid-Serve
|
|||||||
cd /mnt/user/appdata/
|
cd /mnt/user/appdata/
|
||||||
mkdir wrestledesk && cd wrestledesk
|
mkdir wrestledesk && cd wrestledesk
|
||||||
git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git .
|
git clone http://192.168.101.42:3023/PlayMan/WrestleDesk.git .
|
||||||
git checkout feature/pwa # Wichtig: PWA Branch verwenden
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Umgebungsvariablen konfigurieren
|
### 2. Umgebungsvariablen konfigurieren
|
||||||
@@ -32,11 +31,9 @@ nano .env # Oder dein bevorzugter Editor
|
|||||||
# Backend
|
# Backend
|
||||||
SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen
|
SECRET_KEY=dein-sehr-langer-zufälliger-schlüssel-mindestens-50-zeichen
|
||||||
DATABASE_URL=sqlite:///db.sqlite3
|
DATABASE_URL=sqlite:///db.sqlite3
|
||||||
```
|
|
||||||
|
|
||||||
**Tipp für SECRET_KEY:**
|
# Optional: PostgreSQL für bessere Performance
|
||||||
```bash
|
# DATABASE_URL=postgresql://wrestledesk:dein-passwort@db:5432/wrestledesk
|
||||||
openssl rand -base64 50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Docker Container starten
|
### 3. Docker Container starten
|
||||||
@@ -47,32 +44,23 @@ docker-compose up -d --build
|
|||||||
|
|
||||||
Das erste Bauen kann 5-10 Minuten dauern.
|
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`)
|
1. Öffne `http://deine-unraid-ip:81`
|
||||||
2. **Proxy Hosts** → **Add Proxy Host**
|
2. Login: `admin@example.com` / `changeme` (ändern!)
|
||||||
|
3. **Proxy Hosts** → **Add Proxy Host**
|
||||||
|
|
||||||
**Einstellungen für Frontend:**
|
**Einstellungen:**
|
||||||
- **Domain Names:** `rce.playman.top`
|
- **Domain Names:** `rce.playman.top`
|
||||||
- **Scheme:** `http`
|
- **Scheme:** `http`
|
||||||
- **Forward Hostname / IP:** `deine-unraid-ip`
|
- **Forward Hostname / IP:** `frontend`
|
||||||
- **Forward Port:** `3000`
|
- **Forward Port:** `3000`
|
||||||
- **Block Common Exploits:** ✅ Aktivieren
|
- **Block Common Exploits:** ✅ Aktivieren
|
||||||
|
|
||||||
**Einstellungen für Backend (API):**
|
|
||||||
- **Domain Names:** `rce.playman.top`
|
|
||||||
- **Scheme:** `http`
|
|
||||||
- **Forward Hostname / IP:** `deine-unraid-ip`
|
|
||||||
- **Forward Port:** `8000`
|
|
||||||
- **Advanced Tab** → **Custom Locations**:
|
|
||||||
- Location: `/api/v1`
|
|
||||||
- Forward Hostname: `deine-unraid-ip`
|
|
||||||
- Forward Port: `8000`
|
|
||||||
|
|
||||||
**SSL Tab:**
|
**SSL Tab:**
|
||||||
- **SSL Certificate:** `Request a new SSL Certificate`
|
- **SSL Certificate:** `Request a new SSL Certificate`
|
||||||
- **Force SSL:** ✅ Aktivieren
|
- **Force SSL:** ✅ Aktivieren
|
||||||
- **HTTP/2 Support:** ✅ Aktivieren
|
- **Agree to Terms:** ✅ Aktivieren
|
||||||
- **Save**
|
- **Save**
|
||||||
|
|
||||||
### 5. DNS einrichten
|
### 5. DNS einrichten
|
||||||
@@ -118,9 +106,6 @@ docker-compose logs -f backend
|
|||||||
# Datenbank + Media Backup erstellen
|
# Datenbank + Media Backup erstellen
|
||||||
cd /mnt/user/appdata/wrestledesk
|
cd /mnt/user/appdata/wrestledesk
|
||||||
tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/
|
tar czf backup-$(date +%Y%m%d).tar.gz backend/db.sqlite3 backend/media/
|
||||||
|
|
||||||
# Backup zu Unraid Array verschieben
|
|
||||||
mv backup-*.tar.gz /mnt/user/backups/wrestledesk/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Container stoppen
|
### Container stoppen
|
||||||
@@ -129,71 +114,54 @@ mv backup-*.tar.gz /mnt/user/backups/wrestledesk/
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Port-Belegung
|
|
||||||
|
|
||||||
| Service | Port | Beschreibung |
|
|
||||||
|---------|------|--------------|
|
|
||||||
| Frontend | 3000 | Next.js App |
|
|
||||||
| Backend | 8000 | Django API |
|
|
||||||
|
|
||||||
**Wichtig:** Diese Ports müssen von außen NICHT erreichbar sein - nur über Nginx Proxy Manager!
|
|
||||||
|
|
||||||
## Fehlerbehebung
|
## Fehlerbehebung
|
||||||
|
|
||||||
### Frontend zeigt "Backend not reachable"
|
### Port 80 oder 443 ist belegt
|
||||||
|
|
||||||
Prüfe ob die API-URL korrekt ist:
|
|
||||||
```bash
|
|
||||||
docker-compose exec frontend env | grep API_URL
|
|
||||||
# Sollte zeigen: NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORS Fehler im Browser
|
|
||||||
|
|
||||||
Backend .env prüfen:
|
|
||||||
```bash
|
|
||||||
docker-compose exec backend env | grep CORS
|
|
||||||
# Sollte zeigen: CORS_ALLOWED_ORIGINS=https://rce.playman.top
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container starten nicht
|
|
||||||
|
|
||||||
|
Wenn nginx nicht startet:
|
||||||
```bash
|
```bash
|
||||||
# Prüfen was den Port blockiert
|
# Prüfen was den Port blockiert
|
||||||
netstat -tlnp | grep -E '(:3000|:8000)'
|
netstat -tlnp | grep :80
|
||||||
|
|
||||||
# Oder: Container logs prüfen
|
# Oder: Anderen Container stoppen
|
||||||
docker-compose logs --tail 50
|
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 ob Port 80 von außen erreichbar ist
|
||||||
- Prüfe DNS A-Record (sollte auf Unraid-IP zeigen)
|
- Prüfe DNS A-Record
|
||||||
- Warte 24h nach DNS-Änderungen
|
- 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
|
```bash
|
||||||
2. **Verwende ein starkes SECRET_KEY** in .env (min. 50 Zeichen)
|
# Prüfen ob Backend läuft
|
||||||
3. **Aktiviere "Block Common Exploits"** im Nginx Proxy Manager
|
docker-compose ps
|
||||||
4. **Halte Docker Images aktuell:** `docker-compose pull && docker-compose up -d`
|
|
||||||
5. **Backup regelmäßig durchführen**
|
# Backend neu starten
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
```
|
```
|
||||||
Internet
|
Internet
|
||||||
↓
|
↓
|
||||||
Nginx Proxy Manager (auf Unraid, zentral)
|
Nginx Proxy Manager (Port 443)
|
||||||
↓ (Reverse Proxy)
|
↓
|
||||||
┌──────────────┐ ┌──────────────┐
|
Frontend (Next.js) ←→ Backend (Django)
|
||||||
│ Frontend │←──→│ Backend │
|
↓ ↓
|
||||||
│ Port 3000 │ │ Port 8000 │
|
Port 3000 Port 8000
|
||||||
└──────────────┘ └──────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
1. **Ändere das Nginx Proxy Manager Passwort** nach dem ersten Login
|
||||||
|
2. **Verwende ein starkes SECRET_KEY** in .env
|
||||||
|
3. **Aktiviere "Block Common Exploits"** in Nginx Proxy Manager
|
||||||
|
4. **Halte Docker Images aktuell:** `docker-compose pull && docker-compose up -d`
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Bei Problemen:
|
Bei Problemen:
|
||||||
|
|||||||
@@ -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!**
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,11 +3,18 @@ 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"
|
return f"{self.user.username} Profile ({self.get_role_display()})"
|
||||||
|
|
||||||
|
|
||||||
class UserPreferences(models.Model):
|
class UserPreferences(models.Model):
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
from .models import UserPreferences, UserProfile
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@@ -62,3 +62,52 @@ 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)
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
]
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
from rest_framework import status
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
from rest_framework.decorators import api_view, permission_classes, throttle_classes, action
|
||||||
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 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):
|
class AuthRateThrottle(AnonRateThrottle):
|
||||||
@@ -96,3 +102,29 @@ 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)
|
||||||
|
|||||||
@@ -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').split(',')
|
ALLOWED_HOSTS = env('ALLOWED_HOSTS', default='localhost,127.0.0.1,testserver,192.168.101.111').split(',')
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'unfold',
|
'unfold',
|
||||||
@@ -142,7 +142,13 @@ 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 = ['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
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
UNFOLD = {
|
UNFOLD = {
|
||||||
|
|||||||
@@ -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 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 stats.views import dashboard_stats
|
||||||
from leistungstest.views import LeistungstestStatsViewSet
|
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'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),
|
||||||
|
|||||||
+17
-22
@@ -1,36 +1,19 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: wrestledesk-db
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./postgres-data:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=wrestledesk
|
|
||||||
- POSTGRES_USER=wrestledesk
|
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
||||||
networks:
|
|
||||||
- wrestledesk-network
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: wrestledesk-backend
|
container_name: wrestledesk-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
ports:
|
|
||||||
- '10002:8000'
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/media:/app/media
|
- ./backend/media:/app/media
|
||||||
- ./backend/staticfiles:/app/staticfiles
|
- ./backend/staticfiles:/app/staticfiles
|
||||||
environment:
|
environment:
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- DEBUG=False
|
- DEBUG=False
|
||||||
- ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top,192.168.101.42,nginx-proxy-manager
|
- ALLOWED_HOSTS=localhost,127.0.0.1,rce.playman.top
|
||||||
- CORS_ALLOWED_ORIGINS=https://rce.playman.top,http://192.168.101.42:10001,http://192.168.101.42:10002
|
- CORS_ALLOWED_ORIGINS=https://rce.playman.top
|
||||||
- DATABASE_URL=postgresql://wrestledesk:${DB_PASSWORD}@db:5432/wrestledesk
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
networks:
|
networks:
|
||||||
- wrestledesk-network
|
- wrestledesk-network
|
||||||
|
|
||||||
@@ -38,14 +21,26 @@ services:
|
|||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: wrestledesk-frontend
|
container_name: wrestledesk-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- '10001:3000'
|
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
|
- NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
networks:
|
networks:
|
||||||
- wrestledesk-network
|
- wrestledesk-network
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: 'jc21/nginx-proxy-manager:latest'
|
||||||
|
container_name: wrestledesk-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
|
- '81:81'
|
||||||
|
volumes:
|
||||||
|
- ./nginx-data:/data
|
||||||
|
- ./letsencrypt:/etc/letsencrypt
|
||||||
|
networks:
|
||||||
|
- wrestledesk-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
wrestledesk-network:
|
wrestledesk-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
NEXT_PUBLIC_API_URL=https://rce.playman.top/api/v1
|
|
||||||
+35
-16
@@ -2,20 +2,39 @@ const sharp = require('sharp');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const svgPath = path.join(__dirname, 'public/icon-192.svg');
|
const svgBuffer = fs.readFileSync(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);
|
|
||||||
|
|
||||||
Promise.all([
|
// icon-192.png
|
||||||
sharp(svgBuffer).resize(192, 192).png().toFile(path.join(__dirname, 'public/icon-192.png')),
|
sharp(svgBuffer)
|
||||||
sharp(svgBuffer).resize(512, 512).png().toFile(path.join(__dirname, 'public/icon-512.png')),
|
.resize(192, 192)
|
||||||
sharp(svgBuffer).resize(180, 180).png().toFile(path.join(__dirname, 'public/apple-touch-icon.png')),
|
.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'))
|
.toFile('public/icon-192.png')
|
||||||
]).then(() => {
|
.then(() => console.log('Created icon-192.png'));
|
||||||
console.log('Icons created successfully');
|
|
||||||
}).catch((err) => {
|
// icon-512.png
|
||||||
console.error('Icon generation failed:', err);
|
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'));
|
||||||
|
|||||||
Generated
+5
-1
@@ -23,7 +23,6 @@
|
|||||||
"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,6 +37,7 @@
|
|||||||
"@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,6 +1055,7 @@
|
|||||||
"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"
|
||||||
@@ -4192,6 +4193,7 @@
|
|||||||
"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"
|
||||||
@@ -8784,6 +8786,7 @@
|
|||||||
"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": {
|
||||||
@@ -8828,6 +8831,7 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"dev:host": "next dev --hostname 192.168.101.111",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
"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",
|
||||||
@@ -40,6 +38,7 @@
|
|||||||
"@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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -126,49 +126,80 @@
|
|||||||
}
|
}
|
||||||
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 */
|
/* Mobile/PWA Optimierungen */
|
||||||
html { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
html {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
/* iOS Safe Areas */
|
touch-action: manipulation;
|
||||||
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 */
|
/* iOS Safe Areas */
|
||||||
input, select, textarea { font-size: 16px; }
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
/* Hide scrollbar on mobile */
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
@media (max-width: 768px) {
|
padding-left: env(safe-area-inset-left);
|
||||||
::-webkit-scrollbar { display: none; }
|
padding-right: env(safe-area-inset-right);
|
||||||
body { scrollbar-width: none; }
|
}
|
||||||
}
|
|
||||||
|
/* Kein Zoom bei Input-Fokus auf iOS */
|
||||||
/* Minimum touch target 44x44px */
|
input, select, textarea {
|
||||||
button, a, input, select, textarea, [role="button"] { min-height: 44px; min-width: 44px; }
|
font-size: 16px;
|
||||||
|
}
|
||||||
/* Disable hover effects on touch devices */
|
|
||||||
@media (hover: none) { *:hover { transform: none !important; } }
|
/* Scrollbar auf Mobile ausblenden */
|
||||||
|
@media (max-width: 768px) {
|
||||||
/* PWA standalone mode styles */
|
::-webkit-scrollbar {
|
||||||
@media (display-mode: standalone) {
|
display: none;
|
||||||
html { height: 100vh; height: 100dvh; }
|
}
|
||||||
body { overflow: hidden; position: fixed; width: 100%; height: 100%; }
|
body {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimum Touch Target 44x44px */
|
||||||
|
button, a, input, select, textarea, [role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kein Hover-Effekt auf Touch-Geräten */
|
||||||
|
@media (hover: none) {
|
||||||
|
*:hover {
|
||||||
|
-webkit-transform: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PWA App-Look im Standalone-Modus */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
html {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,17 @@ import { Loader2 } from "lucide-react"
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { token } = useAuth()
|
const { token, isHydrated } = 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, router])
|
}, [token, isHydrated, router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ 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"
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ export function InstallPrompt() {
|
|||||||
const [isStandalone, setIsStandalone] = useState(false)
|
const [isStandalone, setIsStandalone] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prüfe ob bereits als PWA installiert
|
||||||
const standalone = window.matchMedia('(display-mode: standalone)').matches ||
|
const standalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
(window.navigator as any).standalone ||
|
(window.navigator as any).standalone ||
|
||||||
document.referrer.includes('android-app://')
|
document.referrer.includes('android-app://')
|
||||||
setIsStandalone(standalone)
|
setIsStandalone(standalone)
|
||||||
|
|
||||||
|
// Prüfe iOS
|
||||||
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
||||||
setIsIOS(isIOSDevice)
|
setIsIOS(isIOSDevice)
|
||||||
|
|
||||||
|
// Zeige Prompt nur wenn nicht bereits installiert und nicht bereits geschlossen
|
||||||
const dismissed = localStorage.getItem('install-prompt-dismissed')
|
const dismissed = localStorage.getItem('install-prompt-dismissed')
|
||||||
if (!standalone && !dismissed) {
|
if (!standalone && !dismissed) {
|
||||||
|
// Verzögert anzeigen
|
||||||
setTimeout(() => setShow(true), 3000)
|
setTimeout(() => setShow(true), 3000)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
@@ -32,23 +36,27 @@ export function InstallPrompt() {
|
|||||||
if (!show || isStandalone) return null
|
if (!show || isStandalone) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-card border-t shadow-lg">
|
<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 items-center justify-between max-w-lg mx-auto">
|
||||||
<div className="flex-1">
|
<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 ? (
|
{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"
|
Tippe auf Teilen → "Zum Home Screen hinzufügen"
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Installieren für schnellen Zugriff
|
Installieren für schnellen Zugriff
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="ghost" onClick={handleDismiss}>
|
<div className="flex items-center gap-2">
|
||||||
<X className="w-4 h-4" />
|
<Button size="sm" variant="ghost" onClick={handleDismiss}>
|
||||||
</Button>
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -265,6 +265,27 @@ 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 {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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>()(
|
||||||
@@ -23,10 +24,12 @@ export const useAuth = create<AuthState>()(
|
|||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: true,
|
||||||
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 })
|
||||||
},
|
},
|
||||||
@@ -101,6 +104,7 @@ export const useAuth = create<AuthState>()(
|
|||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
if (state) {
|
if (state) {
|
||||||
state.isHydrated = true
|
state.isHydrated = true
|
||||||
|
state.isLoading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user