feat: implement user management system
- Add role field to UserProfile (superadmin/admin/trainer) - Add role-based permission classes - Create UserManagementViewSet with CRUD and password change - Add API types and components for user management - Create users management page in settings - Only superadmins can manage users
This commit is contained in:
@@ -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!
|
||||
Reference in New Issue
Block a user