diff --git a/admin_api.py b/admin_api.py index 2da4116..34b3de2 100644 --- a/admin_api.py +++ b/admin_api.py @@ -17,12 +17,16 @@ admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') @jwt_required() @admin_required() def get_users(): - """Listet alle Benutzer auf""" + """Listet alle Benutzer auf (mit Container-Info für Phase 7)""" users = User.query.all() users_list = [] for user in users: - users_list.append(user.to_dict()) + user_dict = user.to_dict() + # Füge Container-Info hinzu (Phase 7) + user_dict['container_count'] = len(user.containers) + user_dict['containers'] = [c.to_dict() for c in user.containers] + users_list.append(user_dict) return jsonify({ 'users': users_list, @@ -59,7 +63,7 @@ def get_user(user_id): @jwt_required() @admin_required() def block_user(user_id): - """Sperrt einen Benutzer""" + """Sperrt einen Benutzer und alle seine Container (Cascading - Phase 7)""" admin_id = get_jwt_identity() if int(admin_id) == user_id: @@ -79,13 +83,32 @@ def block_user(user_id): user.is_blocked = True user.blocked_at = datetime.utcnow() user.blocked_by = int(admin_id) + + # CASCADE: Alle Container des Users blockieren (Phase 7) + container_mgr = ContainerManager() + blocked_containers = 0 + + for container in user.containers: + if not container.is_blocked: + try: + if container.container_id: + container_mgr.stop_container(container.container_id) + except Exception as e: + current_app.logger.warning(f"Container stoppen fehlgeschlagen: {str(e)}") + + container.is_blocked = True + container.blocked_at = datetime.utcnow() + container.blocked_by = int(admin_id) + blocked_containers += 1 + db.session.commit() - current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} gesperrt") + current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} gesperrt (cascade: {blocked_containers} Container blockiert)") return jsonify({ 'message': f'User {user.email} wurde gesperrt', - 'user': user.to_dict() + 'user': user.to_dict(), + 'containers_blocked': blocked_containers }), 200 @@ -93,7 +116,7 @@ def block_user(user_id): @jwt_required() @admin_required() def unblock_user(user_id): - """Entsperrt einen Benutzer""" + """Entsperrt einen Benutzer (User-Level Blockade)""" user = User.query.get(user_id) if not user: @@ -110,9 +133,17 @@ def unblock_user(user_id): admin_id = get_jwt_identity() current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} entsperrt") + # Hinweis: Container-Level Blockaden werden NICHT automatisch aufgehoben + # Diese müssen separat über /api/admin/containers//unblock entsperrt werden + unblocked_containers = 0 + for container in user.containers: + if container.is_blocked: + unblocked_containers += 1 + return jsonify({ 'message': f'User {user.email} wurde entsperrt', - 'user': user.to_dict() + 'user': user.to_dict(), + 'note': f'{unblocked_containers} Container sind noch blockiert und müssen separat entsperrt werden' }), 200 @@ -607,6 +638,146 @@ def debug_management(): return jsonify({'error': f'Unbekannte Action: {action}'}), 400 +# ============================================================ +# Container Blocking Endpoints (Phase 7) +# ============================================================ + +@admin_bp.route('/containers//block', methods=['POST']) +@jwt_required() +@admin_required() +def block_container(container_id): + """Blockiert einen einzelnen User-Container""" + admin_id = get_jwt_identity() + + container = UserContainer.query.get(container_id) + if not container: + return jsonify({'error': 'Container nicht gefunden'}), 404 + + if container.is_blocked: + return jsonify({'error': 'Container ist bereits gesperrt'}), 400 + + # Container stoppen + container_mgr = ContainerManager() + try: + if container.container_id: + container_mgr.stop_container(container.container_id) + except Exception as e: + current_app.logger.warning(f"Container stoppen fehlgeschlagen: {str(e)}") + + # DB aktualisieren + container.is_blocked = True + container.blocked_at = datetime.utcnow() + container.blocked_by = int(admin_id) + db.session.commit() + + current_app.logger.info(f"Container {container.id} ({container.container_type}) gesperrt von Admin {admin_id}") + + return jsonify({ + 'message': f'Container {container.container_type} wurde gesperrt' + }), 200 + + +@admin_bp.route('/containers//unblock', methods=['POST']) +@jwt_required() +@admin_required() +def unblock_container(container_id): + """Entsperrt einen einzelnen User-Container""" + admin_id = get_jwt_identity() + + container = UserContainer.query.get(container_id) + if not container: + return jsonify({'error': 'Container nicht gefunden'}), 404 + + if not container.is_blocked: + return jsonify({'error': 'Container ist nicht gesperrt'}), 400 + + # DB aktualisieren + container.is_blocked = False + container.blocked_at = None + container.blocked_by = None + db.session.commit() + + current_app.logger.info(f"Container {container.id} ({container.container_type}) entsperrt von Admin {admin_id}") + + return jsonify({ + 'message': f'Container {container.container_type} wurde entsperrt', + 'info': 'User kann Container jetzt manuell starten' + }), 200 + + +@admin_bp.route('/containers/bulk-block', methods=['POST']) +@jwt_required() +@admin_required() +def bulk_block_containers(): + """Blockiert mehrere Container gleichzeitig""" + admin_id = get_jwt_identity() + container_ids = request.json.get('container_ids', []) + + if not container_ids: + return jsonify({'error': 'container_ids array required'}), 400 + + success = 0 + failed = [] + container_mgr = ContainerManager() + + for container_id in container_ids: + container = UserContainer.query.get(container_id) + if not container or container.is_blocked: + failed.append(container_id) + continue + + try: + if container.container_id: + container_mgr.stop_container(container.container_id) + except Exception as e: + current_app.logger.warning(f"Container {container_id} stoppen fehlgeschlagen: {str(e)}") + + container.is_blocked = True + container.blocked_at = datetime.utcnow() + container.blocked_by = int(admin_id) + success += 1 + + db.session.commit() + + return jsonify({ + 'message': f'{success} Container gesperrt', + 'failed': failed + }), 200 if not failed else 207 + + +@admin_bp.route('/containers/bulk-unblock', methods=['POST']) +@jwt_required() +@admin_required() +def bulk_unblock_containers(): + """Entsperrt mehrere Container gleichzeitig""" + admin_id = get_jwt_identity() + container_ids = request.json.get('container_ids', []) + + if not container_ids: + return jsonify({'error': 'container_ids array required'}), 400 + + success = 0 + failed = [] + + for container_id in container_ids: + container = UserContainer.query.get(container_id) + if not container or not container.is_blocked: + failed.append(container_id) + continue + + container.is_blocked = False + container.blocked_at = None + container.blocked_by = None + success += 1 + + db.session.commit() + + return jsonify({ + 'message': f'{success} Container entsperrt', + 'failed': failed + }), 200 if not failed else 207 + + @admin_bp.route('/config/reload', methods=['POST']) @jwt_required() @admin_required() diff --git a/api.py b/api.py index 47092e5..eb9379e 100644 --- a/api.py +++ b/api.py @@ -542,7 +542,10 @@ def api_user_containers(): 'service_url': service_url, 'container_id': user_container.container_id if user_container else None, 'created_at': user_container.created_at.isoformat() if user_container and user_container.created_at else None, - 'last_used': user_container.last_used.isoformat() if user_container and user_container.last_used else None + 'last_used': user_container.last_used.isoformat() if user_container and user_container.last_used else None, + # Phase 7: Container Blocking + 'is_blocked': user_container.is_blocked if user_container else False, + 'blocked_at': user_container.blocked_at.isoformat() if user_container and user_container.blocked_at else None }) return jsonify({'containers': containers}), 200 @@ -568,6 +571,13 @@ def api_container_launch(container_type): container_type=container_type ).first() + # Launch-Protection: Blockierte Container dürfen nicht gestartet werden (Phase 7) + if user_container and user_container.is_blocked: + return jsonify({ + 'error': 'Dieser Container wurde von einem Administrator gesperrt', + 'blocked_at': user_container.blocked_at.isoformat() if user_container.blocked_at else None + }), 403 + container_mgr = ContainerManager() if user_container and user_container.container_id: diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index f202a2a..e8d1083 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -112,6 +112,8 @@ export default function AdminPage() { const [actionLoading, setActionLoading] = useState(null); const [searchTerm, setSearchTerm] = useState(""); const [selectedUserIds, setSelectedUserIds] = useState>(new Set()); + const [activeTab, setActiveTab] = useState<"users" | "containers">("users"); + const [selectedContainerIds, setSelectedContainerIds] = useState>(new Set()); const fetchUsers = useCallback(async () => { setIsLoading(true); @@ -403,6 +405,103 @@ export default function AdminPage() { deselectAll(); }; + // Container Actions (Phase 7) + const toggleContainerSelection = (containerId: number) => { + const newSelection = new Set(selectedContainerIds); + if (newSelection.has(containerId)) { + newSelection.delete(containerId); + } else { + newSelection.add(containerId); + } + setSelectedContainerIds(newSelection); + }; + + const handleBlockContainer = async (containerId: number, containerType: string) => { + if (!confirm(`Container "${containerType}" sperren?\n\nDer Container wird gestoppt und kann vom User nicht neu gestartet werden.`)) { + return; + } + + setActionLoading(containerId); + const { error } = await adminApi.blockContainer(containerId); + if (error) { + toast.error(`Fehler: ${error}`); + } else { + toast.success(`Container ${containerType} gesperrt`); + fetchUsers(); + } + setActionLoading(null); + }; + + const handleUnblockContainer = async (containerId: number, containerType: string) => { + setActionLoading(containerId); + const { error } = await adminApi.unblockContainer(containerId); + if (error) { + toast.error(`Fehler: ${error}`); + } else { + toast.success(`Container ${containerType} entsperrt`, { + description: "User kann Container jetzt manuell starten", + }); + fetchUsers(); + } + setActionLoading(null); + }; + + const handleBulkBlockContainers = async () => { + if (!confirm(`${selectedContainerIds.size} Container sperren?`)) { + return; + } + + toast.loading(`Sperre ${selectedContainerIds.size} Container...`, { id: "bulk-block-containers" }); + + let success = 0; + let failed = 0; + + for (const containerId of selectedContainerIds) { + const { error } = await adminApi.blockContainer(containerId); + if (error) { + failed++; + } else { + success++; + } + } + + toast.success(`${success} Container gesperrt`, { + id: "bulk-block-containers", + description: failed > 0 ? `${failed} fehlgeschlagen` : undefined, + }); + + fetchUsers(); + setSelectedContainerIds(new Set()); + }; + + const handleBulkUnblockContainers = async () => { + if (!confirm(`${selectedContainerIds.size} Container entsperren?`)) { + return; + } + + toast.loading(`Entsperre ${selectedContainerIds.size} Container...`, { id: "bulk-unblock-containers" }); + + let success = 0; + let failed = 0; + + for (const containerId of selectedContainerIds) { + const { error } = await adminApi.unblockContainer(containerId); + if (error) { + failed++; + } else { + success++; + } + } + + toast.success(`${success} Container entsperrt`, { + id: "bulk-unblock-containers", + description: failed > 0 ? `${failed} fehlgeschlagen` : undefined, + }); + + fetchUsers(); + setSelectedContainerIds(new Set()); + }; + const handleLogout = async () => { await logout(); router.push("/login"); @@ -472,12 +571,46 @@ export default function AdminPage() { {/* Main Content */}
-

Benutzerverwaltung

+

+ {activeTab === "users" ? "Benutzerverwaltung" : "Container-Verwaltung"} +

- Verwalte alle registrierten Benutzer + {activeTab === "users" ? "Verwalte alle registrierten Benutzer" : "Verwalte alle Benutzer-Container"}

+ {/* Tab Navigation */} +
+ + +
+ {/* Fehler-Alert (Fallback, Toasts sind Primary) */} {error && (
@@ -491,6 +624,8 @@ export default function AdminPage() {
)} + {activeTab === "users" && ( + <> {/* Statistiken */}
@@ -851,6 +986,193 @@ export default function AdminPage() {
+ + )} + + {activeTab === "containers" && ( + <> + {/* Container Bulk-Action Bar */} + {selectedContainerIds.size > 0 && ( +
+
+
+ + {selectedContainerIds.size} Container ausgewählt + + +
+ +
+ + + +
+
+
+ )} + + {/* Suche */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ +
+ + {/* Container Grid */} +
+ {users.flatMap(u => + (u.containers || []).map(container => ( + u.email.toLowerCase().includes(searchTerm.toLowerCase()) || + container.container_type.toLowerCase().includes(searchTerm.toLowerCase()) + ) ? ( + + {/* Checkbox */} +
+ toggleContainerSelection(container.id)} + className="h-4 w-4 rounded border-gray-300" + /> +
+ + {/* Blocked Badge */} + {container.is_blocked && ( +
+ + Gesperrt + +
+ )} + + + {container.container_type} + + User: {u.email} + + + + +
+
+ Status: + + {container.container_id ? "Running" : "Stopped"} + +
+
+ erstellt: + + {container.created_at + ? new Date(container.created_at).toLocaleDateString("de-DE") + : "-"} + +
+ {container.is_blocked && container.blocked_at && ( +
+ Gesperrt: + + {new Date(container.blocked_at).toLocaleString("de-DE")} + +
+ )} +
+ + {/* Action Buttons */} +
+ {container.is_blocked ? ( + + ) : ( + + )} +
+
+
+ ) : null + ))} +
+ + {users.flatMap(u => (u.containers || []).length).reduce((a, b) => a + b, 0) === 0 && ( +
+ Keine Container gefunden +
+ )} + + )}
); diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 314b11a..d64d3a0 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -20,7 +20,9 @@ import { CheckCircle, AlertCircle, Container as ContainerIcon, + ShieldAlert, } from "lucide-react"; +import { toast } from "sonner"; export default function DashboardPage() { const router = useRouter(); @@ -70,7 +72,14 @@ export default function DashboardPage() { // Reload Container-Liste await loadContainers(); } else if (apiError) { - setError(apiError); + // Prüfe auf Blocking-Fehler + if (apiError.includes("Administrator")) { + toast.error("Dieser Container wurde von einem Administrator gesperrt", { + description: "Kontaktiere einen Administrator für mehr Informationen", + }); + } else { + setError(apiError); + } } } catch (err) { setError("Fehler beim Starten des Containers"); @@ -136,8 +145,26 @@ export default function DashboardPage() { ) : (
- {containers.map((container) => ( - + {containers.map((container) => { + const isBlocked = container.is_blocked === true; // Phase 7 + + return ( + + {/* Blocked Badge */} + {isBlocked && ( +
+ + + Gesperrt + +
+ )} +
@@ -145,16 +172,26 @@ export default function DashboardPage() { {container.display_name} - {container.description} + + {isBlocked ? ( + + Dieser Container wurde von einem Administrator gesperrt + + ) : ( + container.description + )} +
- {getStatusIcon(container.status)} + {!isBlocked && getStatusIcon(container.status)}

Status:

-

{getStatusText(container.status)}

+

+ {isBlocked ? "Gesperrt von Admin" : getStatusText(container.status)} +

{container.last_used && ( @@ -166,8 +203,26 @@ export default function DashboardPage() {
)} + {isBlocked && container.blocked_at && ( +
+

Gesperrt am:

+

+ {new Date(container.blocked_at).toLocaleString("de-DE")} +

+
+ )} +
- {container.status === "running" ? ( + {isBlocked ? ( + + ) : container.status === "running" ? (
- ))} + ); + })}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8ba42bf..281b085 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -111,6 +111,21 @@ export interface Container { container_id: string | null; created_at: string | null; last_used: string | null; + is_blocked?: boolean; // Phase 7: Container Blocking + blocked_at?: string | null; // Phase 7 +} + +export interface UserContainer { + id: number; + user_id: number; + container_type: string; + container_id: string | null; + container_port: number | null; + template_image: string; + created_at: string | null; + last_used: string | null; + is_blocked: boolean; // Phase 7 + blocked_at: string | null; // Phase 7 } export interface ContainersResponse { @@ -131,6 +146,8 @@ export interface LaunchResponse { export interface AdminUser extends User { is_blocked: boolean; blocked_at: string | null; + container_count?: number; // Phase 7: Anzahl Containers + containers?: UserContainer[]; // Phase 7: Liste aller Containers } export interface AdminUsersResponse { @@ -290,6 +307,29 @@ export const adminApi = { body: JSON.stringify({ user_ids }), }), + // Container Blocking (Phase 7) + blockContainer: (containerId: number) => + fetchApi<{ message: string }>(`/api/admin/containers/${containerId}/block`, { + method: "POST", + }), + + unblockContainer: (containerId: number) => + fetchApi<{ message: string; info?: string }>(`/api/admin/containers/${containerId}/unblock`, { + method: "POST", + }), + + bulkBlockContainers: (container_ids: number[]) => + fetchApi<{ message: string; failed: number[] }>(`/api/admin/containers/bulk-block`, { + method: "POST", + body: JSON.stringify({ container_ids }), + }), + + bulkUnblockContainers: (container_ids: number[]) => + fetchApi<{ message: string; failed: number[] }>(`/api/admin/containers/bulk-unblock`, { + method: "POST", + body: JSON.stringify({ container_ids }), + }), + // Takeover (Phase 2 - Dummy) startTakeover: (id: number, reason?: string) => fetchApi(`/api/admin/users/${id}/takeover`, { diff --git a/migrate_container_blocking.py b/migrate_container_blocking.py new file mode 100644 index 0000000..0a29718 --- /dev/null +++ b/migrate_container_blocking.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Migration Script: Container Blocking Fields hinzufügen + +Fügt folgende Spalten zur user_container Tabelle hinzu: +- is_blocked (BOOLEAN DEFAULT 0) +- blocked_at (DATETIME) +- blocked_by (INTEGER, Foreign Key zu user.id) + +Verwendung: + python migrate_container_blocking.py + +Fallback (SQLite): + sqlite3 spawner.db < migration.sql +""" + +from app import app, db +import sys + +def migrate(): + """Führt die Migration durch""" + try: + with app.app_context(): + print("[MIGRATION] Starte Container Blocking Migration...") + + # Prüfe ob Spalten bereits existieren + inspector = db.inspect(db.engine) + columns = [col['name'] for col in inspector.get_columns('user_container')] + + if 'is_blocked' in columns: + print("[INFO] Spalte 'is_blocked' existiert bereits") + else: + print("[ADD] Füge Spalte 'is_blocked' hinzu...") + with db.engine.connect() as conn: + try: + conn.execute(db.text(""" + ALTER TABLE user_container + ADD COLUMN is_blocked BOOLEAN DEFAULT 0 NOT NULL + """)) + conn.commit() + print("✅ Spalte 'is_blocked' erstellt") + except Exception as e: + print(f"⚠️ Fehler bei 'is_blocked': {e}") + # Könnte bereits existieren (MySQL) + + if 'blocked_at' in columns: + print("[INFO] Spalte 'blocked_at' existiert bereits") + else: + print("[ADD] Füge Spalte 'blocked_at' hinzu...") + with db.engine.connect() as conn: + try: + conn.execute(db.text(""" + ALTER TABLE user_container + ADD COLUMN blocked_at DATETIME + """)) + conn.commit() + print("✅ Spalte 'blocked_at' erstellt") + except Exception as e: + print(f"⚠️ Fehler bei 'blocked_at': {e}") + + if 'blocked_by' in columns: + print("[INFO] Spalte 'blocked_by' existiert bereits") + else: + print("[ADD] Füge Spalte 'blocked_by' hinzu...") + with db.engine.connect() as conn: + try: + conn.execute(db.text(""" + ALTER TABLE user_container + ADD COLUMN blocked_by INTEGER + REFERENCES user(id) ON DELETE SET NULL + """)) + conn.commit() + print("✅ Spalte 'blocked_by' erstellt") + except Exception as e: + print(f"⚠️ Fehler bei 'blocked_by': {e}") + + print("\n[SUCCESS] Migration abgeschlossen!") + print("[INFO] Folgende Änderungen wurden durchgeführt:") + print(" - is_blocked BOOLEAN DEFAULT 0") + print(" - blocked_at DATETIME") + print(" - blocked_by INTEGER FK zu user(id)") + print("\n[NEXT] Starte die Application mit: docker-compose up -d") + + return True + + except Exception as e: + print(f"\n[ERROR] Migration fehlgeschlagen: {str(e)}") + print("[HELP] Versuche manuelle Migration:") + print(" sqlite3 spawner.db") + print(" > ALTER TABLE user_container ADD COLUMN is_blocked BOOLEAN DEFAULT 0;") + print(" > ALTER TABLE user_container ADD COLUMN blocked_at DATETIME;") + print(" > ALTER TABLE user_container ADD COLUMN blocked_by INTEGER;") + return False + +if __name__ == '__main__': + success = migrate() + sys.exit(0 if success else 1) diff --git a/models.py b/models.py index 88c483c..39900c7 100644 --- a/models.py +++ b/models.py @@ -143,8 +143,14 @@ class UserContainer(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow) last_used = db.Column(db.DateTime) - # Relationship + # Container Blocking (Phase 7) + is_blocked = db.Column(db.Boolean, default=False, nullable=False, index=True) + blocked_at = db.Column(db.DateTime, nullable=True) + blocked_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True) + + # Relationships user = db.relationship('User', back_populates='containers') + blocker = db.relationship('User', foreign_keys=[blocked_by]) # Unique: Ein User kann nur einen Container pro Typ haben __table_args__ = ( @@ -161,7 +167,9 @@ class UserContainer(db.Model): 'container_port': self.container_port, 'template_image': self.template_image, 'created_at': self.created_at.isoformat() if self.created_at else None, - 'last_used': self.last_used.isoformat() if self.last_used else None + 'last_used': self.last_used.isoformat() if self.last_used else None, + 'is_blocked': self.is_blocked, + 'blocked_at': self.blocked_at.isoformat() if self.blocked_at else None }