Files
WrestleDesk/docs/superpowers/plans/2025-03-26-user-management.md
Andrej Spielmann 28222d634d feat: implement user management system
- Add role field to UserProfile (superadmin/admin/trainer)
- Add role-based permission classes
- Create UserManagementViewSet with CRUD and password change
- Add API types and components for user management
- Create users management page in settings
- Only superadmins can manage users
2026-03-26 16:42:08 +01:00

22 KiB

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

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
cd /Volumes/T3/Opencode/WrestleDesk/backend
python manage.py makemigrations auth_app
python manage.py migrate
  • Step 3: Commit
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

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
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

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
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

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
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

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:

path('api/v1/auth/', include('auth_app.urls')),
  • Step 3: Commit
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

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
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

"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
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

"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
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:

{
  title: "Einstellungen",
  icon: Settings,
  href: "/settings",
  subItems: [
    { title: "Benutzer", href: "/settings/users" },
  ],
}
  • Step 2: Commit
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
curl -X GET http://localhost:8000/api/v1/auth/users/ \
  -H "Authorization: Bearer <dein-token>"
  • Step 2: Build Frontend
cd frontend && npm run build
  • Step 3: Final Commit and Push
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!