From d188115db4849d8555488104a1e9944ac4036f4b Mon Sep 17 00:00:00 2001 From: "XPS\\Micro" Date: Sat, 31 Jan 2026 07:01:51 +0100 Subject: [PATCH] Add admin features and email verification Backend: - First registered user becomes admin automatically - Email verification required before login - Admin API with user management endpoints: - Block/unblock users - Reset passwords (sends email) - Delete user containers - Delete users - Resend verification emails - Takeover sessions (Phase 2 dummy) - New decorators: @admin_required, @verified_required - SMTP configuration for email sending - UserState enum (registered/verified/active) - Activity tracking (last_used field) Frontend: - Admin dashboard with color-coded user list - Green: active, recently used - Yellow: warning (unverified/inactive) - Red: critical (long unverified/very long inactive) - Email verification flow (verify-success/verify-error pages) - Signup shows verification instructions - Login handles unverified accounts with resend option - Admin link in dashboard header for admins Co-Authored-By: Claude Opus 4.5 --- .env.example | 16 + admin_api.py | 363 ++++++++++++++ api.py | 174 +++++-- app.py | 4 +- config.py | 13 + decorators.py | 72 +++ email_service.py | 217 +++++++++ frontend/src/app/admin/layout.tsx | 45 ++ frontend/src/app/admin/page.tsx | 581 +++++++++++++++++++++++ frontend/src/app/dashboard/page.tsx | 16 + frontend/src/app/login/page.tsx | 87 +++- frontend/src/app/signup/page.tsx | 71 ++- frontend/src/app/verify-error/page.tsx | 69 +++ frontend/src/app/verify-success/page.tsx | 80 ++++ frontend/src/hooks/use-auth.tsx | 46 +- frontend/src/lib/api.ts | 160 ++++++- models.py | 63 ++- 17 files changed, 2013 insertions(+), 64 deletions(-) create mode 100644 admin_api.py create mode 100644 decorators.py create mode 100644 email_service.py create mode 100644 frontend/src/app/admin/layout.tsx create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/verify-error/page.tsx create mode 100644 frontend/src/app/verify-success/page.tsx diff --git a/.env.example b/.env.example index 7410f33..2c969ae 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,22 @@ LOG_LEVEL=INFO # Container-Timeout in Sekunden (fuer Auto-Shutdown, noch nicht implementiert) CONTAINER_IDLE_TIMEOUT=3600 +# ============================================================ +# EMAIL - Verifizierung und Benachrichtigungen +# ============================================================ + +# SMTP-Server Konfiguration +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@example.com +SMTP_PASSWORD=your-smtp-password +SMTP_FROM=noreply@example.com +SMTP_USE_TLS=true + +# Frontend-URL fuer Email-Links (Verifizierung etc.) +# WICHTIG: Muss die URL sein, unter der das Frontend erreichbar ist +FRONTEND_URL=https://coder.example.com + # ============================================================ # PRODUKTION - Erweiterte Einstellungen # ============================================================ diff --git a/admin_api.py b/admin_api.py new file mode 100644 index 0000000..22db93d --- /dev/null +++ b/admin_api.py @@ -0,0 +1,363 @@ +""" +Admin-API Blueprint +Alle Endpoints erfordern Admin-Rechte. +""" +import secrets +from flask import Blueprint, jsonify, request, current_app +from flask_jwt_extended import jwt_required, get_jwt_identity +from datetime import datetime +from models import db, User, UserState, AdminTakeoverSession +from decorators import admin_required +from container_manager import ContainerManager +from email_service import ( + generate_verification_token, + send_verification_email, + send_password_reset_email +) +from config import Config + +admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') + + +@admin_bp.route('/users', methods=['GET']) +@jwt_required() +@admin_required() +def get_users(): + """Listet alle Benutzer auf""" + users = User.query.all() + + users_list = [] + for user in users: + users_list.append(user.to_dict()) + + return jsonify({ + 'users': users_list, + 'total': len(users_list) + }), 200 + + +@admin_bp.route('/users/', methods=['GET']) +@jwt_required() +@admin_required() +def get_user(user_id): + """Gibt Details eines einzelnen Users zurueck""" + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + # Container-Status abrufen + container_status = 'no_container' + if user.container_id: + try: + container_mgr = ContainerManager() + container_status = container_mgr.get_container_status(user.container_id) + except Exception: + container_status = 'error' + + user_data = user.to_dict() + user_data['container_status'] = container_status + + return jsonify({'user': user_data}), 200 + + +@admin_bp.route('/users//block', methods=['POST']) +@jwt_required() +@admin_required() +def block_user(user_id): + """Sperrt einen Benutzer""" + admin_id = get_jwt_identity() + + if int(admin_id) == user_id: + return jsonify({'error': 'Du kannst dich nicht selbst sperren'}), 400 + + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if user.is_admin: + return jsonify({'error': 'Admins koennen nicht gesperrt werden'}), 400 + + if user.is_blocked: + return jsonify({'error': 'User ist bereits gesperrt'}), 400 + + user.is_blocked = True + user.blocked_at = datetime.utcnow() + user.blocked_by = int(admin_id) + db.session.commit() + + current_app.logger.info(f"User {user.username} wurde von Admin {admin_id} gesperrt") + + return jsonify({ + 'message': f'User {user.username} wurde gesperrt', + 'user': user.to_dict() + }), 200 + + +@admin_bp.route('/users//unblock', methods=['POST']) +@jwt_required() +@admin_required() +def unblock_user(user_id): + """Entsperrt einen Benutzer""" + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if not user.is_blocked: + return jsonify({'error': 'User ist nicht gesperrt'}), 400 + + user.is_blocked = False + user.blocked_at = None + user.blocked_by = None + db.session.commit() + + admin_id = get_jwt_identity() + current_app.logger.info(f"User {user.username} wurde von Admin {admin_id} entsperrt") + + return jsonify({ + 'message': f'User {user.username} wurde entsperrt', + 'user': user.to_dict() + }), 200 + + +@admin_bp.route('/users//reset-password', methods=['POST']) +@jwt_required() +@admin_required() +def reset_user_password(user_id): + """Setzt das Passwort eines Benutzers zurueck""" + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + data = request.get_json() or {} + + # Neues Passwort: entweder angegeben oder zufaellig generiert + new_password = data.get('password') + if not new_password: + new_password = secrets.token_urlsafe(12) + + if len(new_password) < 6: + return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400 + + user.set_password(new_password) + db.session.commit() + + # Email mit neuem Passwort senden + email_sent = send_password_reset_email(user.email, user.username, new_password) + + admin_id = get_jwt_identity() + current_app.logger.info(f"Passwort von User {user.username} wurde von Admin {admin_id} zurueckgesetzt") + + return jsonify({ + 'message': f'Passwort von {user.username} wurde zurueckgesetzt', + 'email_sent': email_sent, + 'password_generated': 'password' not in (data or {}) + }), 200 + + +@admin_bp.route('/users//resend-verification', methods=['POST']) +@jwt_required() +@admin_required() +def resend_user_verification(user_id): + """Sendet Verifizierungs-Email erneut an einen Benutzer""" + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if user.state != UserState.REGISTERED.value: + return jsonify({'error': 'User ist bereits verifiziert'}), 400 + + # Neuen Token generieren + user.verification_token = generate_verification_token() + user.verification_sent_at = datetime.utcnow() + db.session.commit() + + # Email senden + frontend_url = Config.FRONTEND_URL + email_sent = send_verification_email( + user.email, + user.username, + user.verification_token, + frontend_url + ) + + admin_id = get_jwt_identity() + current_app.logger.info(f"Verifizierungs-Email fuer User {user.username} wurde von Admin {admin_id} erneut gesendet") + + return jsonify({ + 'message': f'Verifizierungs-Email an {user.email} gesendet', + 'email_sent': email_sent + }), 200 + + +@admin_bp.route('/users//container', methods=['DELETE']) +@jwt_required() +@admin_required() +def delete_user_container(user_id): + """Loescht den Container eines Benutzers""" + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if not user.container_id: + return jsonify({'error': 'User hat keinen Container'}), 400 + + container_mgr = ContainerManager() + + try: + container_mgr.stop_container(user.container_id) + container_mgr.remove_container(user.container_id) + except Exception as e: + current_app.logger.warning(f"Fehler beim Loeschen des Containers: {str(e)}") + + old_container_id = user.container_id + user.container_id = None + user.container_port = None + db.session.commit() + + admin_id = get_jwt_identity() + current_app.logger.info(f"Container {old_container_id[:12]} von User {user.username} wurde von Admin {admin_id} geloescht") + + return jsonify({ + 'message': f'Container von {user.username} wurde geloescht', + 'user': user.to_dict() + }), 200 + + +@admin_bp.route('/users/', methods=['DELETE']) +@jwt_required() +@admin_required() +def delete_user(user_id): + """Loescht einen Benutzer komplett""" + admin_id = get_jwt_identity() + + if int(admin_id) == user_id: + return jsonify({'error': 'Du kannst dich nicht selbst loeschen'}), 400 + + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if user.is_admin: + return jsonify({'error': 'Admins koennen nicht geloescht werden'}), 400 + + # Container loeschen falls vorhanden + if user.container_id: + container_mgr = ContainerManager() + try: + container_mgr.stop_container(user.container_id) + container_mgr.remove_container(user.container_id) + except Exception as e: + current_app.logger.warning(f"Fehler beim Loeschen des Containers: {str(e)}") + + username = user.username + db.session.delete(user) + db.session.commit() + + current_app.logger.info(f"User {username} wurde von Admin {admin_id} geloescht") + + return jsonify({ + 'message': f'User {username} wurde geloescht' + }), 200 + + +# ============================================================ +# Takeover-Endpoints (Phase 2 - Dummy-Implementierung) +# ============================================================ + +@admin_bp.route('/users//takeover', methods=['POST']) +@jwt_required() +@admin_required() +def start_takeover(user_id): + """ + Startet eine Takeover-Session fuer einen User-Container. + DUMMY-IMPLEMENTIERUNG - wird in Phase 2 vollstaendig implementiert. + """ + admin_id = get_jwt_identity() + data = request.get_json() or {} + reason = data.get('reason', '') + + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if not user.container_id: + return jsonify({'error': 'User hat keinen Container'}), 400 + + # Takeover-Session erstellen (nur Protokollierung) + session = AdminTakeoverSession( + admin_id=int(admin_id), + target_user_id=user_id, + reason=reason + ) + db.session.add(session) + db.session.commit() + + current_app.logger.info(f"Admin {admin_id} hat Takeover fuer User {user.username} gestartet (Session {session.id})") + + return jsonify({ + 'message': 'Takeover-Funktion ist noch nicht vollstaendig implementiert (Phase 2)', + 'session_id': session.id, + 'status': 'dummy', + 'note': 'Diese Funktion wird in einer spaeteren Version verfuegbar sein' + }), 200 + + +@admin_bp.route('/takeover//end', methods=['POST']) +@jwt_required() +@admin_required() +def end_takeover(session_id): + """ + Beendet eine Takeover-Session. + DUMMY-IMPLEMENTIERUNG - wird in Phase 2 vollstaendig implementiert. + """ + session = AdminTakeoverSession.query.get(session_id) + + if not session: + return jsonify({'error': 'Takeover-Session nicht gefunden'}), 404 + + if session.ended_at: + return jsonify({'error': 'Takeover-Session ist bereits beendet'}), 400 + + session.ended_at = datetime.utcnow() + db.session.commit() + + admin_id = get_jwt_identity() + current_app.logger.info(f"Admin {admin_id} hat Takeover-Session {session_id} beendet") + + return jsonify({ + 'message': 'Takeover-Session beendet', + 'session_id': session_id + }), 200 + + +@admin_bp.route('/takeover/active', methods=['GET']) +@jwt_required() +@admin_required() +def get_active_takeovers(): + """Listet alle aktiven Takeover-Sessions auf""" + sessions = AdminTakeoverSession.query.filter_by(ended_at=None).all() + + sessions_list = [] + for session in sessions: + sessions_list.append({ + 'id': session.id, + 'admin_id': session.admin_id, + 'admin_username': session.admin.username if session.admin else None, + 'target_user_id': session.target_user_id, + 'target_username': session.target_user.username if session.target_user else None, + 'started_at': session.started_at.isoformat() if session.started_at else None, + 'reason': session.reason + }) + + return jsonify({ + 'sessions': sessions_list, + 'total': len(sessions_list) + }), 200 diff --git a/api.py b/api.py index dc16e59..061553c 100644 --- a/api.py +++ b/api.py @@ -1,13 +1,15 @@ -from flask import Blueprint, jsonify, request, current_app +from flask import Blueprint, jsonify, request, current_app, redirect from flask_jwt_extended import ( create_access_token, jwt_required, get_jwt_identity, get_jwt ) -from datetime import timedelta -from models import db, User +from datetime import timedelta, datetime +from models import db, User, UserState from container_manager import ContainerManager +from email_service import generate_verification_token, send_verification_email +from config import Config api_bp = Blueprint('api', __name__, url_prefix='/api') @@ -17,11 +19,11 @@ token_blacklist = set() @api_bp.route('/auth/login', methods=['POST']) def api_login(): - """API-Login - gibt JWT-Token zurück""" + """API-Login - gibt JWT-Token zurueck""" data = request.get_json() if not data: - return jsonify({'error': 'Keine Daten übermittelt'}), 400 + return jsonify({'error': 'Keine Daten uebermittelt'}), 400 username = data.get('username') password = data.get('password') @@ -32,7 +34,18 @@ def api_login(): user = User.query.filter_by(username=username).first() if not user or not user.check_password(password): - return jsonify({'error': 'Ungültige Anmeldedaten'}), 401 + return jsonify({'error': 'Ungueltige Anmeldedaten'}), 401 + + # Blockade-Check + if user.is_blocked: + return jsonify({'error': 'Konto gesperrt. Kontaktiere einen Administrator.'}), 403 + + # Verifizierungs-Check + if user.state == UserState.REGISTERED.value: + return jsonify({ + 'error': 'Email nicht verifiziert. Bitte pruefe dein Postfach.', + 'needs_verification': True + }), 403 # Container spawnen wenn noch nicht vorhanden if not user.container_id: @@ -41,17 +54,25 @@ def api_login(): container_id, port = container_mgr.spawn_container(user.id, user.username) user.container_id = container_id user.container_port = port + # State auf ACTIVE setzen bei erstem Container-Start + if user.state == UserState.VERIFIED.value: + user.state = UserState.ACTIVE.value + user.last_used = datetime.utcnow() db.session.commit() except Exception as e: current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}") return jsonify({'error': f'Container-Start fehlgeschlagen: {str(e)}'}), 500 + else: + # last_used aktualisieren + user.last_used = datetime.utcnow() + db.session.commit() # JWT-Token erstellen expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) access_token = create_access_token( identity=str(user.id), expires_delta=expires, - additional_claims={'username': user.username} + additional_claims={'username': user.username, 'is_admin': user.is_admin} ) return jsonify({ @@ -61,18 +82,20 @@ def api_login(): 'user': { 'id': user.id, 'username': user.username, - 'email': user.email + 'email': user.email, + 'is_admin': user.is_admin, + 'state': user.state } }), 200 @api_bp.route('/auth/signup', methods=['POST']) def api_signup(): - """API-Registrierung - erstellt User, spawnt Container, gibt JWT zurück""" + """API-Registrierung - erstellt User und sendet Verifizierungs-Email""" data = request.get_json() if not data: - return jsonify({'error': 'Keine Daten übermittelt'}), 400 + return jsonify({'error': 'Keine Daten uebermittelt'}), 400 username = data.get('username') email = data.get('email') @@ -88,49 +111,53 @@ def api_signup(): if len(password) < 6: return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400 - # Prüfe ob User existiert + # Username-Validierung (nur alphanumerisch und Bindestrich) + import re + if not re.match(r'^[a-zA-Z0-9-]+$', username): + return jsonify({'error': 'Username darf nur Buchstaben, Zahlen und Bindestriche enthalten'}), 400 + + # Pruefe ob User existiert if User.query.filter_by(username=username).first(): return jsonify({'error': 'Username bereits vergeben'}), 409 if User.query.filter_by(email=email).first(): return jsonify({'error': 'Email bereits registriert'}), 409 + # Pruefe ob dies der erste User ist -> wird Admin + is_first_user = User.query.count() == 0 + # Neuen User anlegen user = User(username=username, email=email) user.set_password(password) + user.is_admin = is_first_user + user.state = UserState.REGISTERED.value + user.verification_token = generate_verification_token() + user.verification_sent_at = datetime.utcnow() + db.session.add(user) db.session.commit() - # Container spawnen - try: - container_mgr = ContainerManager() - container_id, port = container_mgr.spawn_container(user.id, user.username) - user.container_id = container_id - user.container_port = port - db.session.commit() - except Exception as e: - db.session.delete(user) - db.session.commit() - current_app.logger.error(f"Registrierung fehlgeschlagen: {str(e)}") - return jsonify({'error': f'Container-Erstellung fehlgeschlagen: {str(e)}'}), 500 - - # JWT-Token erstellen - expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) - access_token = create_access_token( - identity=str(user.id), - expires_delta=expires, - additional_claims={'username': user.username} + # Verifizierungs-Email senden + frontend_url = Config.FRONTEND_URL + email_sent = send_verification_email( + user.email, + user.username, + user.verification_token, + frontend_url ) + if not email_sent: + current_app.logger.warning(f"Verifizierungs-Email konnte nicht gesendet werden an {user.email}") + return jsonify({ - 'access_token': access_token, - 'token_type': 'Bearer', - 'expires_in': int(expires.total_seconds()), + 'message': 'Registrierung erfolgreich. Bitte pruefe dein Postfach und bestatige deine Email-Adresse.', 'user': { 'id': user.id, 'username': user.username, - 'email': user.email - } + 'email': user.email, + 'is_admin': user.is_admin + }, + 'email_sent': email_sent }), 201 @@ -143,10 +170,75 @@ def api_logout(): return jsonify({'message': 'Erfolgreich abgemeldet'}), 200 +@api_bp.route('/auth/verify', methods=['GET']) +def api_verify_email(): + """Email-Verifizierung ueber Token-Link""" + token = request.args.get('token') + frontend_url = Config.FRONTEND_URL + + if not token: + return redirect(f"{frontend_url}/verify-error?reason=missing_token") + + user = User.query.filter_by(verification_token=token).first() + + if not user: + return redirect(f"{frontend_url}/verify-error?reason=invalid_token") + + # Token invalidieren und Status aktualisieren + user.verification_token = None + user.state = UserState.VERIFIED.value + db.session.commit() + + current_app.logger.info(f"User {user.username} hat Email verifiziert") + return redirect(f"{frontend_url}/verify-success") + + +@api_bp.route('/auth/resend-verification', methods=['POST']) +def api_resend_verification(): + """Sendet Verifizierungs-Email erneut""" + data = request.get_json() + + if not data: + return jsonify({'error': 'Keine Daten uebermittelt'}), 400 + + email = data.get('email') + + if not email: + return jsonify({'error': 'Email erforderlich'}), 400 + + user = User.query.filter_by(email=email).first() + + if not user: + # Aus Sicherheitsgruenden kein Fehler wenn User nicht existiert + return jsonify({'message': 'Falls die Email registriert ist, wurde eine neue Verifizierungs-Email gesendet.'}), 200 + + if user.state != UserState.REGISTERED.value: + return jsonify({'error': 'Email bereits verifiziert'}), 400 + + # Neuen Token generieren + user.verification_token = generate_verification_token() + user.verification_sent_at = datetime.utcnow() + db.session.commit() + + # Email senden + frontend_url = Config.FRONTEND_URL + email_sent = send_verification_email( + user.email, + user.username, + user.verification_token, + frontend_url + ) + + return jsonify({ + 'message': 'Falls die Email registriert ist, wurde eine neue Verifizierungs-Email gesendet.', + 'email_sent': email_sent + }), 200 + + @api_bp.route('/user/me', methods=['GET']) @jwt_required() def api_user_me(): - """Gibt aktuellen User und Container-Info zurück""" + """Gibt aktuellen User und Container-Info zurueck""" user_id = get_jwt_identity() user = User.query.get(int(user_id)) @@ -172,6 +264,9 @@ def api_user_me(): 'id': user.id, 'username': user.username, 'email': user.email, + 'is_admin': user.is_admin, + 'state': user.state, + 'last_used': user.last_used.isoformat() if user.last_used else None, 'created_at': user.created_at.isoformat() if user.created_at else None }, 'container': { @@ -232,6 +327,13 @@ def api_container_restart(): container_id, port = container_mgr.spawn_container(user.id, user.username) user.container_id = container_id user.container_port = port + + # State auf ACTIVE setzen bei Container-Start (falls noch VERIFIED) + if user.state == UserState.VERIFIED.value: + user.state = UserState.ACTIVE.value + + # last_used aktualisieren + user.last_used = datetime.utcnow() db.session.commit() return jsonify({ diff --git a/app.py b/app.py index 2aff97b..17a5b82 100644 --- a/app.py +++ b/app.py @@ -3,9 +3,10 @@ from flask_login import LoginManager, login_required, current_user from flask_jwt_extended import JWTManager from flask_cors import CORS from sqlalchemy import text -from models import db, User +from models import db, User, AdminTakeoverSession from auth import auth_bp from api import api_bp, check_if_token_revoked +from admin_api import admin_bp from config import Config from container_manager import ContainerManager @@ -55,6 +56,7 @@ login_manager.login_message_category = 'error' # Blueprints registrieren app.register_blueprint(auth_bp) app.register_blueprint(api_bp) +app.register_blueprint(admin_bp) @login_manager.user_loader def load_user(user_id): diff --git a/config.py b/config.py index 92616fb..0b2a71b 100644 --- a/config.py +++ b/config.py @@ -75,6 +75,19 @@ class Config: # Container-Cleanup CONTAINER_IDLE_TIMEOUT = int(os.getenv('CONTAINER_IDLE_TIMEOUT', 3600)) # 1h in Sekunden + # ======================================== + # SMTP / Email-Konfiguration + # ======================================== + SMTP_HOST = os.getenv('SMTP_HOST', 'localhost') + SMTP_PORT = int(os.getenv('SMTP_PORT', 587)) + SMTP_USER = os.getenv('SMTP_USER', '') + SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', '') + SMTP_FROM = os.getenv('SMTP_FROM', 'noreply@localhost') + SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true' + + # Frontend-URL fuer Email-Links + FRONTEND_URL = os.getenv('FRONTEND_URL', f"http://localhost:3000") + class DevelopmentConfig(Config): """Konfiguration für Entwicklung""" diff --git a/decorators.py b/decorators.py new file mode 100644 index 0000000..086a067 --- /dev/null +++ b/decorators.py @@ -0,0 +1,72 @@ +""" +Decorators fuer Zugriffskontrollen +""" +from functools import wraps +from flask import jsonify +from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity +from models import User + + +def admin_required(): + """ + Decorator der Admin-Rechte prueft. + Muss NACH @jwt_required() verwendet werden. + + Usage: + @api_bp.route('/admin/users') + @jwt_required() + @admin_required() + def get_users(): + ... + """ + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + verify_jwt_in_request() + user_id = get_jwt_identity() + user = User.query.get(int(user_id)) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if not user.is_admin: + return jsonify({'error': 'Admin-Rechte erforderlich'}), 403 + + return fn(*args, **kwargs) + return wrapper + return decorator + + +def verified_required(): + """ + Decorator der prueft ob Email verifiziert ist. + Muss NACH @jwt_required() verwendet werden. + + Usage: + @api_bp.route('/container/action') + @jwt_required() + @verified_required() + def container_action(): + ... + """ + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + from models import UserState + + verify_jwt_in_request() + user_id = get_jwt_identity() + user = User.query.get(int(user_id)) + + if not user: + return jsonify({'error': 'User nicht gefunden'}), 404 + + if user.state == UserState.REGISTERED.value: + return jsonify({ + 'error': 'Email nicht verifiziert', + 'needs_verification': True + }), 403 + + return fn(*args, **kwargs) + return wrapper + return decorator diff --git a/email_service.py b/email_service.py new file mode 100644 index 0000000..c9d2130 --- /dev/null +++ b/email_service.py @@ -0,0 +1,217 @@ +""" +Email-Service fuer Verifizierungs-Emails +""" +import smtplib +import secrets +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from config import Config + + +def generate_verification_token(): + """Generiert einen sicheren Verifizierungs-Token""" + return secrets.token_urlsafe(32) + + +def send_verification_email(user_email, username, token, base_url=None): + """ + Sendet eine Verifizierungs-Email an den Benutzer. + + Args: + user_email: Email-Adresse des Benutzers + username: Benutzername + token: Verifizierungs-Token + base_url: Basis-URL fuer den Verifizierungs-Link (optional) + + Returns: + True bei Erfolg, False bei Fehler + """ + if base_url is None: + base_url = Config.FRONTEND_URL + + verify_url = f"{base_url}/verify-success?token={token}" + + # Email-Inhalt + subject = "Bestatige deine Email-Adresse - Container Spawner" + + html_content = f""" + + + + + + + +
+
+

Container Spawner

+
+
+

Hallo {username}!

+

Vielen Dank fuer deine Registrierung beim Container Spawner.

+

Bitte bestatige deine Email-Adresse, indem du auf den folgenden Button klickst:

+

+ Email bestaetigen +

+

Oder kopiere diesen Link in deinen Browser:

+

+ {verify_url} +

+

Hinweis: Dieser Link ist nur einmal verwendbar.

+
+ +
+ + + """ + + text_content = f""" + Hallo {username}! + + Vielen Dank fuer deine Registrierung beim Container Spawner. + + Bitte bestatige deine Email-Adresse, indem du folgenden Link oeffnest: + + {verify_url} + + Hinweis: Dieser Link ist nur einmal verwendbar. + + --- + Diese Email wurde automatisch generiert. + """ + + # Email erstellen + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = Config.SMTP_FROM + msg['To'] = user_email + + # Text- und HTML-Teil hinzufuegen + part1 = MIMEText(text_content, 'plain', 'utf-8') + part2 = MIMEText(html_content, 'html', 'utf-8') + msg.attach(part1) + msg.attach(part2) + + try: + # SMTP-Verbindung + if Config.SMTP_USE_TLS: + server = smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) + server.starttls() + else: + server = smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) + + # Authentifizierung wenn konfiguriert + if Config.SMTP_USER and Config.SMTP_PASSWORD: + server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) + + # Email senden + server.sendmail(Config.SMTP_FROM, user_email, msg.as_string()) + server.quit() + + print(f"[EMAIL] Verifizierungs-Email gesendet an {user_email}") + return True + + except Exception as e: + print(f"[EMAIL] Fehler beim Senden der Email an {user_email}: {str(e)}") + return False + + +def send_password_reset_email(user_email, username, new_password): + """ + Sendet eine Email mit dem neuen Passwort an den Benutzer. + + Args: + user_email: Email-Adresse des Benutzers + username: Benutzername + new_password: Das neue Passwort + + Returns: + True bei Erfolg, False bei Fehler + """ + subject = "Dein Passwort wurde zurueckgesetzt - Container Spawner" + + html_content = f""" + + + + + + + +
+
+

Container Spawner

+
+
+

Hallo {username}!

+

Ein Administrator hat dein Passwort zurueckgesetzt.

+

Dein neues Passwort lautet:

+

{new_password}

+

Wichtig: Bitte aendere dieses Passwort nach dem ersten Login!

+
+ +
+ + + """ + + text_content = f""" + Hallo {username}! + + Ein Administrator hat dein Passwort zurueckgesetzt. + + Dein neues Passwort lautet: {new_password} + + Wichtig: Bitte aendere dieses Passwort nach dem ersten Login! + + --- + Diese Email wurde automatisch generiert. + """ + + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = Config.SMTP_FROM + msg['To'] = user_email + + part1 = MIMEText(text_content, 'plain', 'utf-8') + part2 = MIMEText(html_content, 'html', 'utf-8') + msg.attach(part1) + msg.attach(part2) + + try: + if Config.SMTP_USE_TLS: + server = smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) + server.starttls() + else: + server = smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) + + if Config.SMTP_USER and Config.SMTP_PASSWORD: + server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) + + server.sendmail(Config.SMTP_FROM, user_email, msg.as_string()) + server.quit() + + print(f"[EMAIL] Passwort-Reset-Email gesendet an {user_email}") + return True + + except Exception as e: + print(f"[EMAIL] Fehler beim Senden der Email an {user_email}: {str(e)}") + return False diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx new file mode 100644 index 0000000..84d1433 --- /dev/null +++ b/frontend/src/app/admin/layout.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; +import { Loader2 } from "lucide-react"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { user, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading) { + if (!user) { + router.replace("/login"); + } else if (!user.is_admin) { + router.replace("/dashboard"); + } + } + }, [user, isLoading, router]); + + // Laden + if (isLoading) { + return ( +
+ +
+ ); + } + + // Nicht eingeloggt oder kein Admin + if (!user || !user.is_admin) { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..56225e0 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,581 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useAuth } from "@/hooks/use-auth"; +import { adminApi, AdminUser } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Input } from "@/components/ui/input"; +import { + Container, + Users, + Shield, + ShieldOff, + Trash2, + KeyRound, + Mail, + RefreshCw, + LogOut, + Loader2, + CheckCircle2, + XCircle, + AlertCircle, + Clock, + ArrowLeft, + Search, + Monitor, +} from "lucide-react"; + +type StatusColor = "green" | "yellow" | "red"; + +function getStatusColor(user: AdminUser): StatusColor { + const now = new Date(); + const lastUsed = user.last_used ? new Date(user.last_used) : null; + const createdAt = user.created_at ? new Date(user.created_at) : now; + + // Berechne Tage seit letzter Nutzung oder Erstellung + const referenceDate = lastUsed || createdAt; + const daysSince = (now.getTime() - referenceDate.getTime()) / (1000 * 60 * 60 * 24); + + switch (user.state) { + case "registered": + // Unverifiziert: gelb < 1 Tag, rot >= 1 Tag + return daysSince >= 1 ? "red" : "yellow"; + case "verified": + // Verifiziert aber nie genutzt: gruen < 1 Tag, gelb >= 1 Tag, rot >= 7 Tage + if (daysSince >= 7) return "red"; + if (daysSince >= 1) return "yellow"; + return "green"; + case "active": + // Aktiv: gruen < 7 Tage, gelb >= 7 Tage, rot >= 30 Tage + if (daysSince >= 30) return "red"; + if (daysSince >= 7) return "yellow"; + return "green"; + default: + return "yellow"; + } +} + +function getStatusBadgeColor(color: StatusColor) { + switch (color) { + case "green": + return "bg-green-100 text-green-800 border-green-200"; + case "yellow": + return "bg-yellow-100 text-yellow-800 border-yellow-200"; + case "red": + return "bg-red-100 text-red-800 border-red-200"; + } +} + +function getStateLabel(state: string) { + switch (state) { + case "registered": + return "Unverifiziert"; + case "verified": + return "Verifiziert"; + case "active": + return "Aktiv"; + default: + return state; + } +} + +function formatDate(dateString: string | null) { + if (!dateString) return "-"; + return new Date(dateString).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function AdminPage() { + const { user, logout } = useAuth(); + const router = useRouter(); + + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [actionLoading, setActionLoading] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + const fetchUsers = useCallback(async () => { + setIsLoading(true); + const { data, error } = await adminApi.getUsers(); + if (data) { + setUsers(data.users); + } else if (error) { + setError(error); + } + setIsLoading(false); + }, []); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const showSuccess = (message: string) => { + setSuccessMessage(message); + setTimeout(() => setSuccessMessage(""), 3000); + }; + + const handleBlock = async (userId: number) => { + setActionLoading(userId); + const { data, error } = await adminApi.blockUser(userId); + if (error) { + setError(error); + } else { + showSuccess(data?.message || "User gesperrt"); + fetchUsers(); + } + setActionLoading(null); + }; + + const handleUnblock = async (userId: number) => { + setActionLoading(userId); + const { data, error } = await adminApi.unblockUser(userId); + if (error) { + setError(error); + } else { + showSuccess(data?.message || "User entsperrt"); + fetchUsers(); + } + setActionLoading(null); + }; + + const handleResetPassword = async (userId: number) => { + if (!confirm("Passwort zuruecksetzen? Der User erhaelt eine Email mit dem neuen Passwort.")) { + return; + } + setActionLoading(userId); + const { data, error } = await adminApi.resetPassword(userId); + if (error) { + setError(error); + } else { + showSuccess(data?.message || "Passwort zurueckgesetzt"); + } + setActionLoading(null); + }; + + const handleResendVerification = async (userId: number) => { + setActionLoading(userId); + const { data, error } = await adminApi.resendVerification(userId); + if (error) { + setError(error); + } else { + showSuccess(data?.message || "Verifizierungs-Email gesendet"); + } + setActionLoading(null); + }; + + const handleDeleteContainer = async (userId: number) => { + if (!confirm("Container wirklich loeschen? Der User kann einen neuen Container starten.")) { + return; + } + setActionLoading(userId); + const { data, error } = await adminApi.deleteUserContainer(userId); + if (error) { + setError(error); + } else { + showSuccess(data?.message || "Container geloescht"); + fetchUsers(); + } + setActionLoading(null); + }; + + const handleDeleteUser = async (userId: number, username: string) => { + if (!confirm(`User "${username}" wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden!`)) { + return; + } + setActionLoading(userId); + const { data, error } = await adminApi.deleteUser(userId); + if (error) { + setError(error); + } else { + showSuccess(data?.message || "User geloescht"); + fetchUsers(); + } + setActionLoading(null); + }; + + const handleTakeover = async (userId: number) => { + const reason = prompt("Grund fuer den Zugriff (optional):"); + if (reason === null) return; // Abgebrochen + + setActionLoading(userId); + const { data, error } = await adminApi.startTakeover(userId, reason); + if (error) { + setError(error); + } else { + alert(data?.note || "Takeover gestartet (Dummy)"); + } + setActionLoading(null); + }; + + const handleLogout = async () => { + await logout(); + router.push("/login"); + }; + + // Gefilterte Users + const filteredUsers = users.filter( + (u) => + u.username.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Statistiken + const stats = { + total: users.length, + active: users.filter((u) => u.state === "active").length, + verified: users.filter((u) => u.state === "verified").length, + unverified: users.filter((u) => u.state === "registered").length, + blocked: users.filter((u) => u.is_blocked).length, + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ + + +
+ + Admin +
+
+
+
+ + + {user?.username.slice(0, 2).toUpperCase()} + + + {user?.username} + + Admin + +
+ +
+
+
+ + {/* Main Content */} +
+
+

Benutzerverwaltung

+

+ Verwalte alle registrierten Benutzer +

+
+ + {/* Nachrichten */} + {error && ( +
+ {error} + +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Statistiken */} +
+ + + +
+

{stats.total}

+

Gesamt

+
+
+
+ + + +
+

{stats.active}

+

Aktiv

+
+
+
+ + + +
+

{stats.verified}

+

Verifiziert

+
+
+
+ + + +
+

{stats.unverified}

+

Unverifiziert

+
+
+
+ + + +
+

{stats.blocked}

+

Gesperrt

+
+
+
+
+ + {/* Suche */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ +
+ + {/* Benutzerliste */} + + + Benutzer + + {filteredUsers.length} von {users.length} Benutzern + + + +
+ {filteredUsers.map((u) => { + const statusColor = getStatusColor(u); + const isCurrentUser = u.id === user?.id; + + return ( +
+ {/* User Info */} +
+ + + {u.username.slice(0, 2).toUpperCase()} + + +
+
+ {u.username} + {u.is_admin && ( + + Admin + + )} + {u.is_blocked && ( + + Gesperrt + + )} +
+

{u.email}

+
+ + {statusColor === "green" && ( + + )} + {statusColor === "yellow" && ( + + )} + {statusColor === "red" && ( + + )} + {getStateLabel(u.state)} + + | + + Letzte Aktivitaet: {formatDate(u.last_used)} + + {u.container_id && ( + <> + | + + + {u.container_id.slice(0, 8)} + + + )} +
+
+
+ + {/* Aktionen */} +
+ {actionLoading === u.id ? ( + + ) : ( + <> + {/* Verifizierungs-Email */} + {u.state === "registered" && ( + + )} + + {/* Passwort zuruecksetzen */} + + + {/* Container loeschen */} + {u.container_id && ( + + )} + + {/* Takeover (Dummy) */} + {u.container_id && !isCurrentUser && ( + + )} + + {/* Sperren/Entsperren */} + {!isCurrentUser && !u.is_admin && ( + <> + {u.is_blocked ? ( + + ) : ( + + )} + + )} + + {/* Loeschen */} + {!isCurrentUser && !u.is_admin && ( + + )} + + )} +
+
+ ); + })} + + {filteredUsers.length === 0 && ( +
+ Keine Benutzer gefunden +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 33a5314..51f2d6e 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -24,7 +24,9 @@ import { CheckCircle2, XCircle, AlertCircle, + Shield, } from "lucide-react"; +import Link from "next/link"; export default function DashboardPage() { const { user, logout, isLoading: authLoading } = useAuth(); @@ -130,6 +132,15 @@ export default function DashboardPage() { Container Spawner
+ {/* Admin-Link */} + {user.is_admin && ( + + + + )}
@@ -137,6 +148,11 @@ export default function DashboardPage() { {user.username} + {user.is_admin && ( + + Admin + + )}
+
+ + + + )} +
setPassword(e.target.value)} required diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index 6994b01..3c4c3b3 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -14,7 +14,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Container, Loader2 } from "lucide-react"; +import { Container, Loader2, Mail, CheckCircle2 } from "lucide-react"; export default function SignupPage() { const [username, setUsername] = useState(""); @@ -23,6 +23,8 @@ export default function SignupPage() { const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [signupSuccess, setSignupSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); const { signup, user, isLoading } = useAuth(); const router = useRouter(); @@ -38,7 +40,7 @@ export default function SignupPage() { setError(""); if (password !== confirmPassword) { - setError("Passworter stimmen nicht uberein"); + setError("Passwoerter stimmen nicht ueberein"); return; } @@ -52,12 +54,19 @@ export default function SignupPage() { return; } + // Validiere Username-Format + if (!/^[a-zA-Z0-9-]+$/.test(username)) { + setError("Benutzername darf nur Buchstaben, Zahlen und Bindestriche enthalten"); + return; + } + setIsSubmitting(true); const result = await signup(username, email, password); if (result.success) { - router.push("/dashboard"); + setSignupSuccess(true); + setSuccessMessage(result.message || "Registrierung erfolgreich!"); } else { setError(result.error || "Registrierung fehlgeschlagen"); setIsSubmitting(false); @@ -72,6 +81,48 @@ export default function SignupPage() { ); } + // Erfolgsanzeige nach Registrierung + if (signupSuccess) { + return ( +
+ + +
+ +
+ + Registrierung erfolgreich! + + {successMessage} +
+ +
+
+ +
+

Pruefe dein Postfach

+

+ Wir haben eine Verifizierungs-Email an{" "} + {email} gesendet. Klicke auf den Link in + der Email, um dein Konto zu aktivieren. +

+
+
+
+
+

Keine Email erhalten?

+

Pruefe deinen Spam-Ordner oder versuche dich anzumelden,

+

um eine neue Verifizierungs-Email anzufordern.

+
+ +
+
+
+ ); + } + return (
@@ -103,7 +154,8 @@ export default function SignupPage() { disabled={isSubmitting} />

- Dieser wird Teil deiner Service-URL + Nur Buchstaben, Zahlen und Bindestriche. Wird Teil deiner + Service-URL.

@@ -117,13 +169,16 @@ export default function SignupPage() { required disabled={isSubmitting} /> +

+ Du erhaeltst eine Verifizierungs-Email an diese Adresse. +

setPassword(e.target.value)} required @@ -131,11 +186,11 @@ export default function SignupPage() { />
- + setConfirmPassword(e.target.value)} required @@ -146,7 +201,7 @@ export default function SignupPage() { {isSubmitting ? ( <> - Container wird erstellt... + Registrierung laeuft... ) : ( "Registrieren" diff --git a/frontend/src/app/verify-error/page.tsx b/frontend/src/app/verify-error/page.tsx new file mode 100644 index 0000000..caf77a3 --- /dev/null +++ b/frontend/src/app/verify-error/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { XCircle, RefreshCw, Mail } from "lucide-react"; + +export default function VerifyErrorPage() { + const searchParams = useSearchParams(); + const reason = searchParams.get("reason"); + + const getErrorMessage = () => { + switch (reason) { + case "missing_token": + return "Der Verifizierungs-Link ist unvollstaendig."; + case "invalid_token": + return "Der Verifizierungs-Link ist ungueltig oder bereits verwendet worden."; + case "expired_token": + return "Der Verifizierungs-Link ist abgelaufen."; + default: + return "Bei der Verifizierung ist ein Fehler aufgetreten."; + } + }; + + return ( +
+ + +
+ +
+ Verifizierung fehlgeschlagen + {getErrorMessage()} +
+ +

+ Moegliche Gruende: +

+
    +
  • Der Link wurde bereits verwendet
  • +
  • Der Link wurde nicht vollstaendig kopiert
  • +
  • Du hast einen neueren Verifizierungs-Link erhalten
  • +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/app/verify-success/page.tsx b/frontend/src/app/verify-success/page.tsx new file mode 100644 index 0000000..02f8918 --- /dev/null +++ b/frontend/src/app/verify-success/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { CheckCircle2, Container, Loader2 } from "lucide-react"; + +export default function VerifySuccessPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const [isVerifying, setIsVerifying] = useState(!!token); + const [verified, setVerified] = useState(!token); + + useEffect(() => { + // Wenn ein Token in der URL ist, wurde der User vom Backend hierher redirected + // und die Verifizierung ist bereits erfolgt + if (token) { + // Kurze Verzoegerung fuer bessere UX + const timer = setTimeout(() => { + setIsVerifying(false); + setVerified(true); + }, 1000); + return () => clearTimeout(timer); + } + }, [token]); + + if (isVerifying) { + return ( +
+ + + +

+ Email wird verifiziert... +

+
+
+
+ ); + } + + return ( +
+ + +
+ +
+ Email verifiziert! + + Deine Email-Adresse wurde erfolgreich bestaetigt. + +
+ +

+ Du kannst dich jetzt mit deinen Zugangsdaten anmelden und deinen + Container nutzen. +

+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx index d9d2907..4e54ab7 100644 --- a/frontend/src/hooks/use-auth.tsx +++ b/frontend/src/hooks/use-auth.tsx @@ -9,10 +9,12 @@ import { } from "react"; import { api, LoginResponse, UserResponse } from "@/lib/api"; -interface User { +export interface User { id: number; username: string; email: string; + is_admin: boolean; + state: "registered" | "verified" | "active"; } interface AuthContextType { @@ -22,12 +24,12 @@ interface AuthContextType { login: ( username: string, password: string - ) => Promise<{ success: boolean; error?: string }>; + ) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>; signup: ( username: string, email: string, password: string - ) => Promise<{ success: boolean; error?: string }>; + ) => Promise<{ success: boolean; error?: string; message?: string }>; logout: () => Promise; refreshUser: () => Promise; } @@ -58,7 +60,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const { data, error } = await api.getUser(); if (data && !error) { - setUser(data.user); + setUser({ + id: data.user.id, + username: data.user.username, + email: data.user.email, + is_admin: data.user.is_admin, + state: data.user.state, + }); } else { localStorage.removeItem("token"); setToken(null); @@ -70,16 +78,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { const login = async ( username: string, password: string - ): Promise<{ success: boolean; error?: string }> => { + ): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> => { const { data, error } = await api.login(username, password); if (error || !data) { - return { success: false, error: error || "Login fehlgeschlagen" }; + // Pruefe ob Verifizierung erforderlich + const needsVerification = error?.includes("nicht verifiziert"); + return { + success: false, + error: error || "Login fehlgeschlagen", + needsVerification + }; } localStorage.setItem("token", data.access_token); setToken(data.access_token); - setUser(data.user); + setUser({ + id: data.user.id, + username: data.user.username, + email: data.user.email, + is_admin: data.user.is_admin, + state: data.user.state, + }); return { success: true }; }; @@ -87,17 +107,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { username: string, email: string, password: string - ): Promise<{ success: boolean; error?: string }> => { + ): Promise<{ success: boolean; error?: string; message?: string }> => { const { data, error } = await api.signup(username, email, password); if (error || !data) { return { success: false, error: error || "Registrierung fehlgeschlagen" }; } - localStorage.setItem("token", data.access_token); - setToken(data.access_token); - setUser(data.user); - return { success: true }; + // Nach Signup wird kein Token mehr zurueckgegeben + // User muss erst Email verifizieren + return { + success: true, + message: data.message + }; }; const logout = async () => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 18fd3db..772b78d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -39,6 +39,10 @@ async function fetchApi( } } +// ============================================================ +// Auth Interfaces +// ============================================================ + export interface LoginResponse { access_token: string; token_type: string; @@ -47,14 +51,30 @@ export interface LoginResponse { id: number; username: string; email: string; + is_admin: boolean; + state: "registered" | "verified" | "active"; }; } +export interface SignupResponse { + message: string; + user: { + id: number; + username: string; + email: string; + is_admin: boolean; + }; + email_sent: boolean; +} + export interface UserResponse { user: { id: number; username: string; email: string; + is_admin: boolean; + state: "registered" | "verified" | "active"; + last_used: string | null; created_at: string | null; }; container: { @@ -76,7 +96,68 @@ export interface ContainerRestartResponse { status: string; } +// ============================================================ +// Admin Interfaces +// ============================================================ + +export interface AdminUser { + id: number; + username: string; + email: string; + is_admin: boolean; + is_blocked: boolean; + blocked_at: string | null; + state: "registered" | "verified" | "active"; + last_used: string | null; + created_at: string | null; + container_id: string | null; +} + +export interface AdminUsersResponse { + users: AdminUser[]; + total: number; +} + +export interface AdminUserResponse { + user: AdminUser & { + container_status: string; + }; +} + +export interface AdminActionResponse { + message: string; + user?: AdminUser; + email_sent?: boolean; +} + +export interface TakeoverResponse { + message: string; + session_id: number; + status: string; + note?: string; +} + +export interface TakeoverSession { + id: number; + admin_id: number; + admin_username: string | null; + target_user_id: number; + target_username: string | null; + started_at: string | null; + reason: string | null; +} + +export interface ActiveTakeoversResponse { + sessions: TakeoverSession[]; + total: number; +} + +// ============================================================ +// API Functions +// ============================================================ + export const api = { + // Auth login: (username: string, password: string) => fetchApi("/api/auth/login", { method: "POST", @@ -84,7 +165,7 @@ export const api = { }), signup: (username: string, email: string, password: string) => - fetchApi("/api/auth/signup", { + fetchApi("/api/auth/signup", { method: "POST", body: JSON.stringify({ username, email, password }), }), @@ -94,8 +175,19 @@ export const api = { method: "POST", }), + resendVerification: (email: string) => + fetchApi<{ message: string; email_sent: boolean }>( + "/api/auth/resend-verification", + { + method: "POST", + body: JSON.stringify({ email }), + } + ), + + // User getUser: () => fetchApi("/api/user/me"), + // Container getContainerStatus: () => fetchApi("/api/container/status"), @@ -104,3 +196,69 @@ export const api = { method: "POST", }), }; + +// ============================================================ +// Admin API Functions +// ============================================================ + +export const adminApi = { + // Users + getUsers: () => fetchApi("/api/admin/users"), + + getUser: (id: number) => + fetchApi(`/api/admin/users/${id}`), + + // Block/Unblock + blockUser: (id: number) => + fetchApi(`/api/admin/users/${id}/block`, { + method: "POST", + }), + + unblockUser: (id: number) => + fetchApi(`/api/admin/users/${id}/unblock`, { + method: "POST", + }), + + // Password Reset + resetPassword: (id: number, password?: string) => + fetchApi(`/api/admin/users/${id}/reset-password`, { + method: "POST", + body: JSON.stringify(password ? { password } : {}), + }), + + // Verification + resendVerification: (id: number) => + fetchApi(`/api/admin/users/${id}/resend-verification`, { + method: "POST", + }), + + // Container + deleteUserContainer: (id: number) => + fetchApi(`/api/admin/users/${id}/container`, { + method: "DELETE", + }), + + // Delete User + deleteUser: (id: number) => + fetchApi(`/api/admin/users/${id}`, { + method: "DELETE", + }), + + // Takeover (Phase 2 - Dummy) + startTakeover: (id: number, reason?: string) => + fetchApi(`/api/admin/users/${id}/takeover`, { + method: "POST", + body: JSON.stringify({ reason: reason || "" }), + }), + + endTakeover: (sessionId: number) => + fetchApi<{ message: string; session_id: number }>( + `/api/admin/takeover/${sessionId}/end`, + { + method: "POST", + } + ), + + getActiveTakeovers: () => + fetchApi("/api/admin/takeover/active"), +}; diff --git a/models.py b/models.py index e2b20ee..f22713b 100644 --- a/models.py +++ b/models.py @@ -2,9 +2,19 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime +from enum import Enum + db = SQLAlchemy() + +class UserState(Enum): + """Benutzer-Status fuer Email-Verifizierung und Aktivitaet""" + REGISTERED = 'registered' # Signup abgeschlossen, Email nicht verifiziert + VERIFIED = 'verified' # Email verifiziert, Container noch nie genutzt + ACTIVE = 'active' # Container mindestens einmal gestartet + + class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) @@ -13,9 +23,56 @@ class User(UserMixin, db.Model): container_id = db.Column(db.String(100), nullable=True) container_port = db.Column(db.Integer, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) - + + # Admin-Felder + is_admin = db.Column(db.Boolean, default=False, nullable=False) + + # Sperr-Felder + is_blocked = db.Column(db.Boolean, default=False, nullable=False) + blocked_at = db.Column(db.DateTime, nullable=True) + blocked_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + + # Email-Verifizierung und Status + state = db.Column(db.String(20), default=UserState.REGISTERED.value, nullable=False) + verification_token = db.Column(db.String(64), nullable=True) + verification_sent_at = db.Column(db.DateTime, nullable=True) + + # Aktivitaetstracking + last_used = db.Column(db.DateTime, nullable=True) + + # Beziehung fuer blocked_by + blocker = db.relationship('User', remote_side=[id], foreign_keys=[blocked_by]) + def set_password(self, password): self.password_hash = generate_password_hash(password) - + def check_password(self, password): - return check_password_hash(self.password_hash, password) \ No newline at end of file + return check_password_hash(self.password_hash, password) + + def to_dict(self): + """Konvertiert User zu Dictionary fuer API-Responses""" + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'is_admin': self.is_admin, + 'is_blocked': self.is_blocked, + 'blocked_at': self.blocked_at.isoformat() if self.blocked_at else None, + 'state': self.state, + 'last_used': self.last_used.isoformat() if self.last_used else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'container_id': self.container_id + } + + +class AdminTakeoverSession(db.Model): + """Protokolliert Admin-Zugriffe auf User-Container (Phase 2)""" + id = db.Column(db.Integer, primary_key=True) + admin_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + target_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + started_at = db.Column(db.DateTime, default=datetime.utcnow) + ended_at = db.Column(db.DateTime, nullable=True) + reason = db.Column(db.String(500), nullable=True) + + admin = db.relationship('User', foreign_keys=[admin_id]) + target_user = db.relationship('User', foreign_keys=[target_user_id]) \ No newline at end of file