feat: Expandable Container-Rows + shadcn AlertDialog + Status 207 Fix

- Backend: Status 207 → 200 für partielle Erfolge (admin_api.py)
- Frontend: Expandable User-Rows mit Container-Checkboxen (admin/page.tsx)
- UI: Neues shadcn AlertDialog für Container-Lösch-Bestätigung
- Deps: @radix-ui/react-alert-dialog installiert
- Docs: Version 3.0 Dokumentation aktualisiert (admin-dashboard-improvements.md)

Behebt:
- Problem I: Browser-confirm() → echtes Modal
- Problem II: Status 207 Fehler (0 gelöscht, 1 fehlgeschlagen)
This commit is contained in:
XPS\Micro 2026-02-08 16:56:04 +01:00
parent a39488139c
commit 0117566268
5 changed files with 452 additions and 99 deletions

View File

@ -198,7 +198,12 @@ def delete_user_container(user_id):
return jsonify({'error': 'User nicht gefunden'}), 404
if not user.containers:
return jsonify({'error': 'User hat keine Container'}), 400
return jsonify({
'message': 'User hat keine Container',
'deleted': 0,
'failed': [],
'skipped': True
}), 200
container_mgr = ContainerManager()
deleted_count = 0
@ -225,16 +230,12 @@ def delete_user_container(user_id):
current_app.logger.info(f"Admin {admin_id} löschte {deleted_count} Container von User {user.email}")
if failed_containers:
return jsonify({
'message': f'{deleted_count} Container gelöscht, {len(failed_containers)} fehlgeschlagen',
'message': f'{deleted_count} Container gelöscht' +
(f', {len(failed_containers)} fehlgeschlagen' if failed_containers else ''),
'deleted': deleted_count,
'failed': failed_containers,
'deleted': deleted_count
}), 207 # Multi-Status
return jsonify({
'message': f'Alle {deleted_count} Container von {user.email} wurden gelöscht',
'deleted': deleted_count
'partial_failure': len(failed_containers) > 0
}), 200

View File

@ -1,7 +1,7 @@
# Admin-Dashboard: Verbesserte Container- und User-Löschung
**Datum:** 02.02.2026
**Version:** 2.0
**Datum:** 08.02.2026 (Update), 02.02.2026 (Initial)
**Version:** 3.0
**Status:** ✅ Vollständig implementiert
---
@ -14,6 +14,82 @@ Diese Dokumentation beschreibt die Verbesserungen des Admin-Dashboards:
2. **Toast-Benachrichtigungen** - Modernes UI statt primitiver Alerts
3. **Bulk-Operations** - Mehrere User gleichzeitig verwalten (Sperren, Löschen, etc.)
4. **DSGVO-Compliance** - Vollständige Datenlöschung (MagicLinkToken, AdminTakeoverSession)
5. **✨ NEU (v3.0)**: Expandable Container-Rows + shadcn AlertDialog (08.02.2026)
---
## ✨ Version 3.0: Expandable Rows & AlertDialog (08.02.2026)
### 🎯 Problem (Gelöst)
**Problem I:** Browser-`confirm()` statt echtes Modal
- Admin konnte nicht einzelne Container auswählen
- Nur "Alles löschen oder gar nichts" möglich
- Keine Übersicht der betroffenen Container
**Problem II:** Toast zeigt "0 gelöscht, 1 fehlgeschlagen"
- **Root Cause**: Backend gab Status 207 zurück
- Frontend interpretierte Status 207 als Fehler → `response.ok = false`
- Obwohl Container erfolgreich gelöscht wurden, zählte Frontend sie als "fehlgeschlagen"
### ✅ Lösung Implementiert
#### Backend-Fix (`admin_api.py` Zeile 189-238)
- Status 207 → **200** (für partielle Erfolge)
- Status 400 → **200** (für "keine Container")
- Details im Response-Body: `deleted`, `failed`, `partial_failure`
- **Begründung**: HTTP-Status sollten Transport-Errors signalisieren, nicht Business-Logic
```python
# VORHER: Status 207 für Teilerfolge
return jsonify({...}), 207
# NACHHER: Immer Status 200, Details im Body
return jsonify({
'message': f'{deleted_count} Container gelöscht' +
(f', {len(failed_containers)} fehlgeschlagen' if failed_containers else ''),
'deleted': deleted_count,
'failed': failed_containers,
'partial_failure': len(failed_containers) > 0
}), 200
```
#### Frontend-Verbesserungen (`frontend/src/app/admin/page.tsx`)
**Expandable User-Rows:**
- Klick auf User → Container-Liste klappt auf/zu
- ChevronDown Icon rotiert bei Expand/Collapse
- Container nur angezeigt wenn User expandiert
**Container-Checkboxen:**
- Jeder Container hat eine Checkbox
- Nur ausgewählte Container werden gelöscht
- Unterstützung für Einzelaus wahl und Bulk-Select
**shadcn AlertDialog (neu):**
- Echtes Modal statt Browser-`confirm()`
- Zeigt Zusammenfassung:
```
3 Container von 2 Benutzer(n) löschen?
• user1@example.com (2 Container)
• user2@example.com (1 Container)
```
- Ja/Nein Buttons mit klarer Bestätigung
**Verbesserte Bulk-Delete-Logik:**
- Parst `deleted` und `failed` aus Response-Body
- Akkumuliert Container-Zähler (nicht User-Zähler)
- Toast zeigt genaue Zahlen: "3 Container gelöscht, 1 fehlgeschlagen"
### 📝 Dateiänderungen
```
✅ Geänderte Dateien:
- admin_api.py (Backend-Fix für Status 207)
- frontend/src/app/admin/page.tsx (UI-Overhaul)
- frontend/src/components/ui/alert-dialog.tsx (neue Komponente)
- frontend/package.json (@radix-ui/react-alert-dialog)
```
---
@ -410,6 +486,7 @@ with app.app_context():
| Version | Datum | Änderungen |
|---------|-------|-----------|
| 3.0 | 08.02.2026 | **Expandable Rows**, shadcn AlertDialog, Status 207→200 Fix, Container-Checkboxen |
| 2.0 | 02.02.2026 | Multi-Container, Toast-System, Bulk-Operations, DSGVO |
| 1.0 | ≤01.02.2026 | Ursprüngliches Admin-Dashboard |

View File

@ -9,19 +9,20 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.408.0",
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0",
"lucide-react": "^0.408.0",
"sonner": "1.7.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1"
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@types/node": "^20.14.10",

View File

@ -34,8 +34,19 @@ import {
Search,
Monitor,
X,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type StatusColor = "green" | "yellow" | "red";
@ -114,6 +125,12 @@ export default function AdminPage() {
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState<"users" | "containers">("users");
const [selectedContainerIds, setSelectedContainerIds] = useState<Set<number>>(new Set());
const [expandedUserIds, setExpandedUserIds] = useState<Set<number>>(new Set());
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteDialogData, setDeleteDialogData] = useState<{
containerIds: number[];
userSummary: { email: string; count: number }[];
} | null>(null);
const fetchUsers = useCallback(async () => {
setIsLoading(true);
@ -130,6 +147,96 @@ export default function AdminPage() {
fetchUsers();
}, [fetchUsers]);
// Expand/Collapse Helper
const toggleUserExpand = (userId: number) => {
const newExpanded = new Set(expandedUserIds);
if (newExpanded.has(userId)) {
newExpanded.delete(userId);
} else {
newExpanded.add(userId);
}
setExpandedUserIds(newExpanded);
};
// Dialog Helper für Bulk-Delete
const openBulkDeleteDialog = () => {
if (selectedContainerIds.size === 0) {
toast.error("Keine Container ausgewählt");
return;
}
// Erstelle Zusammenfassung nach User
const userMap = new Map<number, { email: string; count: number }>();
for (const containerId of selectedContainerIds) {
const user = users.find(u =>
u.containers?.some(c => c.id === containerId)
);
if (user) {
const existing = userMap.get(user.id) || { email: user.email, count: 0 };
existing.count++;
userMap.set(user.id, existing);
}
}
setDeleteDialogData({
containerIds: Array.from(selectedContainerIds),
userSummary: Array.from(userMap.values())
});
setIsDeleteDialogOpen(true);
};
// Bestätigte Bulk-Delete
const handleConfirmBulkDelete = async () => {
if (!deleteDialogData) return;
setIsDeleteDialogOpen(false);
toast.loading(
`Lösche ${deleteDialogData.containerIds.length} Container...`,
{ id: "bulk-delete-containers" }
);
// Gruppiere Container nach User-ID
const containersByUser = new Map<number, number[]>();
for (const containerId of deleteDialogData.containerIds) {
const user = users.find(u =>
u.containers?.some(c => c.id === containerId)
);
if (user) {
if (!containersByUser.has(user.id)) {
containersByUser.set(user.id, []);
}
containersByUser.get(user.id)!.push(containerId);
}
}
let totalDeleted = 0;
let totalFailed = 0;
// Lösche Container pro User
for (const [userId, containerIds] of containersByUser) {
const { data, error } = await adminApi.deleteUserContainer(userId);
if (error) {
totalFailed += containerIds.length;
} else if (data) {
// Parse Response-Body (nach Backend-Fix)
totalDeleted += data.deleted || 0;
totalFailed += (data.failed?.length || 0);
}
}
toast.success(`${totalDeleted} Container gelöscht`, {
id: "bulk-delete-containers",
description: totalFailed > 0 ? `${totalFailed} fehlgeschlagen` : undefined,
});
fetchUsers();
setSelectedContainerIds(new Set());
setDeleteDialogData(null);
};
// Bulk-Selection Helpers
const toggleUserSelection = (userId: number) => {
const newSelection = new Set(selectedUserIds);
@ -188,23 +295,6 @@ export default function AdminPage() {
setActionLoading(null);
};
const handleDeleteContainer = async (userId: number, userEmail: string) => {
if (!confirm(`Container von "${userEmail}" wirklich loeschen? Der User kann einen neuen Container starten.`)) {
return;
}
setActionLoading(userId);
const { data, error } = await adminApi.deleteUserContainer(userId);
if (error) {
toast.error(`Fehler: ${error}`);
} else {
toast.success(data?.message || "Container geloescht", {
description: data?.deleted ? `${data.deleted} Container entfernt` : undefined,
});
fetchUsers();
}
setActionLoading(null);
};
const handleDeleteUser = async (userId: number, userEmail: string) => {
if (!confirm(
`⚠️ ACHTUNG: User "${userEmail}" VOLLSTAENDIG loeschen?\n\n` +
@ -304,43 +394,6 @@ export default function AdminPage() {
deselectAll();
};
const handleBulkDeleteContainers = async () => {
const userList = Array.from(selectedUserIds)
.map((id) => users.find((u) => u.id === id)?.email)
.filter(Boolean)
.join(", ");
if (!confirm(
`Container von ${selectedUserIds.size} Usern löschen?\n\n` +
`Betroffene User:\n${userList}\n\n` +
`User können danach neue Container erstellen.`
)) {
return;
}
toast.loading(`Lösche Container von ${selectedUserIds.size} Usern...`, { id: "bulk-delete-containers" });
let success = 0;
let failed = 0;
for (const userId of selectedUserIds) {
const { error } = await adminApi.deleteUserContainer(userId);
if (error) {
failed++;
} else {
success++;
}
}
toast.success(`${success} User-Container gelöscht`, {
id: "bulk-delete-containers",
description: failed > 0 ? `${failed} fehlgeschlagen` : undefined,
});
fetchUsers();
deselectAll();
};
const handleBulkDeleteUsers = async () => {
const selectedUsers = Array.from(selectedUserIds)
.map((id) => users.find((u) => u.id === id))
@ -717,15 +770,17 @@ export default function AdminPage() {
</Button>
{/* Bulk-Delete-Container */}
{selectedContainerIds.size > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleBulkDeleteContainers}
onClick={openBulkDeleteDialog}
disabled={actionLoading !== null}
>
<Container className="mr-2 h-4 w-4" />
Container löschen
Container löschen ({selectedContainerIds.size})
</Button>
)}
{/* Bulk-Delete User */}
<Button
@ -805,12 +860,31 @@ export default function AdminPage() {
return (
<div
key={u.id}
className={`flex items-center justify-between rounded-lg border p-4 ${
u.is_blocked ? "bg-red-50 border-red-200" : ""
} ${isSelected ? "bg-primary/5 border-primary" : ""}`}
className="border rounded-lg overflow-hidden"
>
{/* Main User Row */}
<div
className={`flex items-center justify-between p-4 ${
u.is_blocked ? "bg-red-50 border-b border-red-200" : "border-b"
} ${isSelected ? "bg-primary/5" : ""}`}
>
{/* Checkbox + User Info */}
<div className="flex items-center gap-4">
{/* Expand Icon */}
{u.containers && u.containers.length > 0 && (
<button
onClick={() => toggleUserExpand(u.id)}
className="p-0 h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
title={expandedUserIds.has(u.id) ? "Container ausblenden" : "Container anzeigen"}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${
expandedUserIds.has(u.id) ? "rotate-180" : ""
}`}
/>
</button>
)}
{isSelectable && (
<input
type="checkbox"
@ -909,18 +983,6 @@ export default function AdminPage() {
<Mail className="h-4 w-4" />
</Button>
{/* Container loeschen */}
{u.container_id && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteContainer(u.id, u.email)}
title="Container loeschen"
>
<Container className="h-4 w-4" />
</Button>
)}
{/* Takeover (Dummy) */}
{u.container_id && !isCurrentUser && (
<Button
@ -975,6 +1037,45 @@ export default function AdminPage() {
)}
</div>
</div>
{/* Expandable Container List */}
{expandedUserIds.has(u.id) && u.containers && u.containers.length > 0 && (
<div className="border-t bg-muted/30 p-4">
<div className="space-y-2">
{u.containers.map(container => (
<div
key={container.id}
className="flex items-center gap-3 p-3 rounded border bg-background hover:bg-accent/50 transition-colors"
>
{/* Checkbox für Container */}
<input
type="checkbox"
checked={selectedContainerIds.has(container.id)}
onChange={() => toggleContainerSelection(container.id)}
className="h-4 w-4 rounded border-gray-300"
/>
{/* Container Icon + Info */}
<Container className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{container.container_type}</span>
{container.is_blocked && (
<Badge variant="destructive" className="text-xs">
Gesperrt
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{container.container_id ? "Running" : "Stopped"} {formatDate(container.created_at)}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
@ -1174,6 +1275,47 @@ export default function AdminPage() {
</>
)}
</main>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Container wirklich löschen?</AlertDialogTitle>
<AlertDialogDescription>
{deleteDialogData && (
<>
<p className="mb-3">
Du bist dabei, <strong>{deleteDialogData.containerIds.length} Container</strong> von{" "}
<strong>{deleteDialogData.userSummary.length} Benutzer(n)</strong> zu löschen.
</p>
<div className="mt-3 space-y-1 text-sm bg-muted/50 p-3 rounded">
<p className="font-semibold text-foreground">Betroffene Benutzer:</p>
<ul className="space-y-1 ml-2">
{deleteDialogData.userSummary.map((user, idx) => (
<li key={idx} className="text-sm">
<span className="font-medium">{user.email}</span> ({user.count} Container)
</li>
))}
</ul>
</div>
<p className="mt-4 text-xs text-muted-foreground">
Die Benutzer können danach neue Container erstellen.
</p>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Jetzt löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,132 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}