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:
Andrej Spielmann
2026-03-26 16:42:08 +01:00
parent 7611533718
commit 28222d634d
19 changed files with 1960 additions and 7 deletions
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+4 -1
View File
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
images: {
unoptimized: true,
},
};
export default nextConfig;
+55
View File
@@ -0,0 +1,55 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 3000;
const PUBLIC_DIR = path.join(__dirname, 'public');
const server = http.createServer((req, res) => {
let filePath = path.join(PUBLIC_DIR, req.url === '/' ? 'index.html' : req.url);
// Set CORS headers for PWA
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
// Set cache headers for icons and manifest
if (req.url.includes('.png') || req.url.includes('.json') || req.url.includes('.svg')) {
res.setHeader('Cache-Control', 'public, max-age=31536000');
}
const ext = path.extname(filePath);
const contentTypes = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
const contentType = contentTypes[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>404 Not Found</h1>');
} else {
res.writeHead(500);
res.end(`Server Error: ${err.code}`);
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
});
});
server.listen(PORT, () => {
console.log(`PWA Server running at http://localhost:${PORT}`);
});
+160
View File
@@ -0,0 +1,160 @@
"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>
)
}
+21
View File
@@ -265,6 +265,27 @@ export interface IUser {
last_name?: string
club_id?: number | null
club_name?: string | null
is_active?: boolean
role?: string
date_joined?: string
}
export interface ICreateUserInput {
username: string
email: string
first_name: string
last_name: string
password: string
role: 'superadmin' | 'admin' | 'trainer'
}
export interface IUpdateUserInput {
username?: string
email?: string
first_name?: string
last_name?: string
is_active?: boolean
role?: 'superadmin' | 'admin' | 'trainer'
}
export interface IAuthResponse {