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 <noreply@anthropic.com>
This commit is contained in:
XPS\Micro 2026-01-31 07:01:51 +01:00
parent b6fd832311
commit d188115db4
17 changed files with 2013 additions and 64 deletions

View File

@ -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
# ============================================================

363
admin_api.py Normal file
View File

@ -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/<int:user_id>', 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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>', 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/<int:user_id>/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/<int:session_id>/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

174
api.py
View File

@ -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({

4
app.py
View File

@ -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):

View File

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

72
decorators.py Normal file
View File

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

217
email_service.py Normal file
View File

@ -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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #1a1a2e; color: white; padding: 20px; text-align: center; }}
.content {{ padding: 30px; background: #f9f9f9; }}
.button {{ display: inline-block; padding: 12px 30px; background: #4f46e5; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }}
.footer {{ padding: 20px; text-align: center; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Container Spawner</h1>
</div>
<div class="content">
<h2>Hallo {username}!</h2>
<p>Vielen Dank fuer deine Registrierung beim Container Spawner.</p>
<p>Bitte bestatige deine Email-Adresse, indem du auf den folgenden Button klickst:</p>
<p style="text-align: center;">
<a href="{verify_url}" class="button">Email bestaetigen</a>
</p>
<p>Oder kopiere diesen Link in deinen Browser:</p>
<p style="word-break: break-all; background: #eee; padding: 10px; border-radius: 3px;">
{verify_url}
</p>
<p><strong>Hinweis:</strong> Dieser Link ist nur einmal verwendbar.</p>
</div>
<div class="footer">
<p>Diese Email wurde automatisch generiert. Bitte antworte nicht darauf.</p>
</div>
</div>
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #1a1a2e; color: white; padding: 20px; text-align: center; }}
.content {{ padding: 30px; background: #f9f9f9; }}
.password {{ background: #eee; padding: 15px; font-family: monospace; font-size: 18px; text-align: center; border-radius: 5px; }}
.footer {{ padding: 20px; text-align: center; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Container Spawner</h1>
</div>
<div class="content">
<h2>Hallo {username}!</h2>
<p>Ein Administrator hat dein Passwort zurueckgesetzt.</p>
<p>Dein neues Passwort lautet:</p>
<p class="password">{new_password}</p>
<p><strong>Wichtig:</strong> Bitte aendere dieses Passwort nach dem ersten Login!</p>
</div>
<div class="footer">
<p>Diese Email wurde automatisch generiert. Bitte antworte nicht darauf.</p>
</div>
</div>
</body>
</html>
"""
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

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
// Nicht eingeloggt oder kein Admin
if (!user || !user.is_admin) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return <>{children}</>;
}

View File

@ -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<AdminUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [actionLoading, setActionLoading] = useState<number | null>(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 (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="min-h-screen bg-muted/50">
{/* Header */}
<header className="border-b bg-background">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-4">
<Link href="/dashboard">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Dashboard
</Button>
</Link>
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-primary" />
<span className="text-lg font-semibold">Admin</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{user?.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{user?.username}</span>
<Badge variant="secondary" className="text-xs">
Admin
</Badge>
</div>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Abmelden
</Button>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto p-4 md:p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Benutzerverwaltung</h1>
<p className="text-muted-foreground">
Verwalte alle registrierten Benutzer
</p>
</div>
{/* Nachrichten */}
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-sm text-destructive">
{error}
<button
onClick={() => setError("")}
className="ml-2 underline"
>
Schliessen
</button>
</div>
)}
{successMessage && (
<div className="mb-6 rounded-md bg-green-100 p-4 text-sm text-green-800">
{successMessage}
</div>
)}
{/* Statistiken */}
<div className="mb-6 grid gap-4 md:grid-cols-5">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Users className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{stats.total}</p>
<p className="text-xs text-muted-foreground">Gesamt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<CheckCircle2 className="h-8 w-8 text-green-500" />
<div>
<p className="text-2xl font-bold">{stats.active}</p>
<p className="text-xs text-muted-foreground">Aktiv</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Clock className="h-8 w-8 text-blue-500" />
<div>
<p className="text-2xl font-bold">{stats.verified}</p>
<p className="text-xs text-muted-foreground">Verifiziert</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<AlertCircle className="h-8 w-8 text-yellow-500" />
<div>
<p className="text-2xl font-bold">{stats.unverified}</p>
<p className="text-xs text-muted-foreground">Unverifiziert</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<ShieldOff className="h-8 w-8 text-red-500" />
<div>
<p className="text-2xl font-bold">{stats.blocked}</p>
<p className="text-xs text-muted-foreground">Gesperrt</p>
</div>
</CardContent>
</Card>
</div>
{/* Suche */}
<div className="mb-6 flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Benutzer suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={fetchUsers}>
<RefreshCw className="mr-2 h-4 w-4" />
Aktualisieren
</Button>
</div>
{/* Benutzerliste */}
<Card>
<CardHeader>
<CardTitle>Benutzer</CardTitle>
<CardDescription>
{filteredUsers.length} von {users.length} Benutzern
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{filteredUsers.map((u) => {
const statusColor = getStatusColor(u);
const isCurrentUser = u.id === user?.id;
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" : ""
}`}
>
{/* User Info */}
<div className="flex items-center gap-4">
<Avatar>
<AvatarFallback
className={`${
u.is_blocked
? "bg-red-200 text-red-800"
: u.is_admin
? "bg-primary text-primary-foreground"
: ""
}`}
>
{u.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{u.username}</span>
{u.is_admin && (
<Badge variant="secondary" className="text-xs">
Admin
</Badge>
)}
{u.is_blocked && (
<Badge variant="destructive" className="text-xs">
Gesperrt
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{u.email}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${getStatusBadgeColor(
statusColor
)}`}
>
{statusColor === "green" && (
<CheckCircle2 className="h-3 w-3" />
)}
{statusColor === "yellow" && (
<AlertCircle className="h-3 w-3" />
)}
{statusColor === "red" && (
<XCircle className="h-3 w-3" />
)}
{getStateLabel(u.state)}
</span>
<span>|</span>
<span>
Letzte Aktivitaet: {formatDate(u.last_used)}
</span>
{u.container_id && (
<>
<span>|</span>
<span className="flex items-center gap-1">
<Container className="h-3 w-3" />
{u.container_id.slice(0, 8)}
</span>
</>
)}
</div>
</div>
</div>
{/* Aktionen */}
<div className="flex items-center gap-2">
{actionLoading === u.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
{/* Verifizierungs-Email */}
{u.state === "registered" && (
<Button
variant="ghost"
size="sm"
onClick={() => handleResendVerification(u.id)}
title="Verifizierungs-Email erneut senden"
>
<Mail className="h-4 w-4" />
</Button>
)}
{/* Passwort zuruecksetzen */}
<Button
variant="ghost"
size="sm"
onClick={() => handleResetPassword(u.id)}
title="Passwort zuruecksetzen"
>
<KeyRound className="h-4 w-4" />
</Button>
{/* Container loeschen */}
{u.container_id && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteContainer(u.id)}
title="Container loeschen"
>
<Container className="h-4 w-4" />
</Button>
)}
{/* Takeover (Dummy) */}
{u.container_id && !isCurrentUser && (
<Button
variant="ghost"
size="sm"
onClick={() => handleTakeover(u.id)}
title="Container-Zugriff (Phase 2)"
disabled
>
<Monitor className="h-4 w-4" />
</Button>
)}
{/* Sperren/Entsperren */}
{!isCurrentUser && !u.is_admin && (
<>
{u.is_blocked ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleUnblock(u.id)}
title="Entsperren"
>
<Shield className="h-4 w-4 text-green-600" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleBlock(u.id)}
title="Sperren"
>
<ShieldOff className="h-4 w-4 text-red-600" />
</Button>
)}
</>
)}
{/* Loeschen */}
{!isCurrentUser && !u.is_admin && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(u.id, u.username)}
title="Benutzer loeschen"
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
</div>
);
})}
{filteredUsers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
Keine Benutzer gefunden
</div>
)}
</div>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@ -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() {
<span className="text-lg font-semibold">Container Spawner</span>
</div>
<div className="flex items-center gap-4">
{/* Admin-Link */}
{user.is_admin && (
<Link href="/admin">
<Button variant="outline" size="sm">
<Shield className="mr-2 h-4 w-4" />
Admin
</Button>
</Link>
)}
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
@ -137,6 +148,11 @@ export default function DashboardPage() {
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{user.username}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-xs">
Admin
</Badge>
)}
</div>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -14,13 +15,17 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Container, Loader2 } from "lucide-react";
import { Container, Loader2, Mail, AlertCircle } from "lucide-react";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [needsVerification, setNeedsVerification] = useState(false);
const [resendingEmail, setResendingEmail] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const { login, user, isLoading } = useAuth();
const router = useRouter();
@ -34,6 +39,8 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setNeedsVerification(false);
setEmailSent(false);
setIsSubmitting(true);
const result = await login(username, password);
@ -42,10 +49,33 @@ export default function LoginPage() {
router.push("/dashboard");
} else {
setError(result.error || "Login fehlgeschlagen");
if (result.needsVerification) {
setNeedsVerification(true);
}
setIsSubmitting(false);
}
};
const handleResendVerification = async () => {
if (!email) {
setError("Bitte gib deine Email-Adresse ein");
return;
}
setResendingEmail(true);
setError("");
const { data, error } = await api.resendVerification(email);
if (error) {
setError(error);
} else {
setEmailSent(true);
}
setResendingEmail(false);
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
@ -70,9 +100,60 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
</div>
)}
{/* Verifizierungs-Hinweis */}
{needsVerification && (
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-yellow-600" />
<div className="space-y-2">
<p className="text-sm font-medium text-yellow-800">
Email nicht verifiziert
</p>
<p className="text-sm text-yellow-700">
Bitte pruefe dein Postfach und klicke auf den
Verifizierungs-Link. Falls du keine Email erhalten hast,
kannst du eine neue anfordern.
</p>
<div className="space-y-2">
<Input
type="email"
placeholder="Deine Email-Adresse"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-white"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResendVerification}
disabled={resendingEmail || emailSent}
className="w-full"
>
{resendingEmail ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird gesendet...
</>
) : emailSent ? (
"Email gesendet!"
) : (
"Neue Verifizierungs-Email senden"
)}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input
@ -90,7 +171,7 @@ export default function LoginPage() {
<Input
id="password"
type="password"
placeholder="••••••••"
placeholder="Dein Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
required

View File

@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-2xl font-bold">
Registrierung erfolgreich!
</CardTitle>
<CardDescription>{successMessage}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/50 p-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-primary" />
<div>
<p className="font-medium">Pruefe dein Postfach</p>
<p className="text-sm text-muted-foreground">
Wir haben eine Verifizierungs-Email an{" "}
<strong>{email}</strong> gesendet. Klicke auf den Link in
der Email, um dein Konto zu aktivieren.
</p>
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
<p>Keine Email erhalten?</p>
<p>Pruefe deinen Spam-Ordner oder versuche dich anzumelden,</p>
<p>um eine neue Verifizierungs-Email anzufordern.</p>
</div>
<Button asChild className="w-full">
<Link href="/login">Zum Login</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
@ -103,7 +154,8 @@ export default function SignupPage() {
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Dieser wird Teil deiner Service-URL
Nur Buchstaben, Zahlen und Bindestriche. Wird Teil deiner
Service-URL.
</p>
</div>
<div className="space-y-2">
@ -117,13 +169,16 @@ export default function SignupPage() {
required
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Du erhaeltst eine Verifizierungs-Email an diese Adresse.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
placeholder="Mindestens 6 Zeichen"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@ -131,11 +186,11 @@ export default function SignupPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Passwort bestatigen</Label>
<Label htmlFor="confirmPassword">Passwort bestaetigen</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
placeholder="Passwort wiederholen"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@ -146,7 +201,7 @@ export default function SignupPage() {
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Container wird erstellt...
Registrierung laeuft...
</>
) : (
"Registrieren"

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-muted/50 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<XCircle className="h-8 w-8 text-red-600" />
</div>
<CardTitle className="text-2xl">Verifizierung fehlgeschlagen</CardTitle>
<CardDescription>{getErrorMessage()}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-sm text-muted-foreground">
Moegliche Gruende:
</p>
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>Der Link wurde bereits verwendet</li>
<li>Der Link wurde nicht vollstaendig kopiert</li>
<li>Du hast einen neueren Verifizierungs-Link erhalten</li>
</ul>
<div className="flex flex-col gap-2 pt-4">
<Button asChild variant="outline" className="w-full">
<Link href="/login">
<RefreshCw className="mr-2 h-4 w-4" />
Zum Login (neue Email anfordern)
</Link>
</Button>
<Button asChild variant="ghost" className="w-full">
<Link href="/signup">
<Mail className="mr-2 h-4 w-4" />
Neu registrieren
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-muted/50 px-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-center text-muted-foreground">
Email wird verifiziert...
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/50 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<CardTitle className="text-2xl">Email verifiziert!</CardTitle>
<CardDescription>
Deine Email-Adresse wurde erfolgreich bestaetigt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-sm text-muted-foreground">
Du kannst dich jetzt mit deinen Zugangsdaten anmelden und deinen
Container nutzen.
</p>
<div className="flex flex-col gap-2">
<Button asChild className="w-full">
<Link href="/login">
<Container className="mr-2 h-4 w-4" />
Zum Login
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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<void>;
refreshUser: () => Promise<void>;
}
@ -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 () => {

View File

@ -39,6 +39,10 @@ async function fetchApi<T>(
}
}
// ============================================================
// 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<LoginResponse>("/api/auth/login", {
method: "POST",
@ -84,7 +165,7 @@ export const api = {
}),
signup: (username: string, email: string, password: string) =>
fetchApi<LoginResponse>("/api/auth/signup", {
fetchApi<SignupResponse>("/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<UserResponse>("/api/user/me"),
// Container
getContainerStatus: () =>
fetchApi<ContainerStatusResponse>("/api/container/status"),
@ -104,3 +196,69 @@ export const api = {
method: "POST",
}),
};
// ============================================================
// Admin API Functions
// ============================================================
export const adminApi = {
// Users
getUsers: () => fetchApi<AdminUsersResponse>("/api/admin/users"),
getUser: (id: number) =>
fetchApi<AdminUserResponse>(`/api/admin/users/${id}`),
// Block/Unblock
blockUser: (id: number) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/block`, {
method: "POST",
}),
unblockUser: (id: number) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/unblock`, {
method: "POST",
}),
// Password Reset
resetPassword: (id: number, password?: string) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify(password ? { password } : {}),
}),
// Verification
resendVerification: (id: number) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/resend-verification`, {
method: "POST",
}),
// Container
deleteUserContainer: (id: number) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/container`, {
method: "DELETE",
}),
// Delete User
deleteUser: (id: number) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}`, {
method: "DELETE",
}),
// Takeover (Phase 2 - Dummy)
startTakeover: (id: number, reason?: string) =>
fetchApi<TakeoverResponse>(`/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<ActiveTakeoversResponse>("/api/admin/takeover/active"),
};

View File

@ -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)
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])