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:
parent
b6fd832311
commit
d188115db4
16
.env.example
16
.env.example
|
|
@ -87,6 +87,22 @@ LOG_LEVEL=INFO
|
||||||
# Container-Timeout in Sekunden (fuer Auto-Shutdown, noch nicht implementiert)
|
# Container-Timeout in Sekunden (fuer Auto-Shutdown, noch nicht implementiert)
|
||||||
CONTAINER_IDLE_TIMEOUT=3600
|
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
|
# PRODUKTION - Erweiterte Einstellungen
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
363
admin_api.py
Normal file
363
admin_api.py
Normal 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
174
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 (
|
from flask_jwt_extended import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
jwt_required,
|
jwt_required,
|
||||||
get_jwt_identity,
|
get_jwt_identity,
|
||||||
get_jwt
|
get_jwt
|
||||||
)
|
)
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
from models import db, User
|
from models import db, User, UserState
|
||||||
from container_manager import ContainerManager
|
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')
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
@ -17,11 +19,11 @@ token_blacklist = set()
|
||||||
|
|
||||||
@api_bp.route('/auth/login', methods=['POST'])
|
@api_bp.route('/auth/login', methods=['POST'])
|
||||||
def api_login():
|
def api_login():
|
||||||
"""API-Login - gibt JWT-Token zurück"""
|
"""API-Login - gibt JWT-Token zurueck"""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'error': 'Keine Daten übermittelt'}), 400
|
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
||||||
|
|
||||||
username = data.get('username')
|
username = data.get('username')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
|
|
@ -32,7 +34,18 @@ def api_login():
|
||||||
user = User.query.filter_by(username=username).first()
|
user = User.query.filter_by(username=username).first()
|
||||||
|
|
||||||
if not user or not user.check_password(password):
|
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
|
# Container spawnen wenn noch nicht vorhanden
|
||||||
if not user.container_id:
|
if not user.container_id:
|
||||||
|
|
@ -41,17 +54,25 @@ def api_login():
|
||||||
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
||||||
user.container_id = container_id
|
user.container_id = container_id
|
||||||
user.container_port = port
|
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()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}")
|
current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}")
|
||||||
return jsonify({'error': f'Container-Start fehlgeschlagen: {str(e)}'}), 500
|
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
|
# JWT-Token erstellen
|
||||||
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
identity=str(user.id),
|
identity=str(user.id),
|
||||||
expires_delta=expires,
|
expires_delta=expires,
|
||||||
additional_claims={'username': user.username}
|
additional_claims={'username': user.username, 'is_admin': user.is_admin}
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -61,18 +82,20 @@ def api_login():
|
||||||
'user': {
|
'user': {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'email': user.email
|
'email': user.email,
|
||||||
|
'is_admin': user.is_admin,
|
||||||
|
'state': user.state
|
||||||
}
|
}
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/auth/signup', methods=['POST'])
|
@api_bp.route('/auth/signup', methods=['POST'])
|
||||||
def api_signup():
|
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()
|
data = request.get_json()
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'error': 'Keine Daten übermittelt'}), 400
|
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
||||||
|
|
||||||
username = data.get('username')
|
username = data.get('username')
|
||||||
email = data.get('email')
|
email = data.get('email')
|
||||||
|
|
@ -88,49 +111,53 @@ def api_signup():
|
||||||
if len(password) < 6:
|
if len(password) < 6:
|
||||||
return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400
|
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():
|
if User.query.filter_by(username=username).first():
|
||||||
return jsonify({'error': 'Username bereits vergeben'}), 409
|
return jsonify({'error': 'Username bereits vergeben'}), 409
|
||||||
|
|
||||||
if User.query.filter_by(email=email).first():
|
if User.query.filter_by(email=email).first():
|
||||||
return jsonify({'error': 'Email bereits registriert'}), 409
|
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
|
# Neuen User anlegen
|
||||||
user = User(username=username, email=email)
|
user = User(username=username, email=email)
|
||||||
user.set_password(password)
|
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.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Container spawnen
|
# Verifizierungs-Email senden
|
||||||
try:
|
frontend_url = Config.FRONTEND_URL
|
||||||
container_mgr = ContainerManager()
|
email_sent = send_verification_email(
|
||||||
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
user.email,
|
||||||
user.container_id = container_id
|
user.username,
|
||||||
user.container_port = port
|
user.verification_token,
|
||||||
db.session.commit()
|
frontend_url
|
||||||
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}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not email_sent:
|
||||||
|
current_app.logger.warning(f"Verifizierungs-Email konnte nicht gesendet werden an {user.email}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'access_token': access_token,
|
'message': 'Registrierung erfolgreich. Bitte pruefe dein Postfach und bestatige deine Email-Adresse.',
|
||||||
'token_type': 'Bearer',
|
|
||||||
'expires_in': int(expires.total_seconds()),
|
|
||||||
'user': {
|
'user': {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'email': user.email
|
'email': user.email,
|
||||||
}
|
'is_admin': user.is_admin
|
||||||
|
},
|
||||||
|
'email_sent': email_sent
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -143,10 +170,75 @@ def api_logout():
|
||||||
return jsonify({'message': 'Erfolgreich abgemeldet'}), 200
|
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'])
|
@api_bp.route('/user/me', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def api_user_me():
|
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_id = get_jwt_identity()
|
||||||
user = User.query.get(int(user_id))
|
user = User.query.get(int(user_id))
|
||||||
|
|
||||||
|
|
@ -172,6 +264,9 @@ def api_user_me():
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'email': user.email,
|
'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
|
'created_at': user.created_at.isoformat() if user.created_at else None
|
||||||
},
|
},
|
||||||
'container': {
|
'container': {
|
||||||
|
|
@ -232,6 +327,13 @@ def api_container_restart():
|
||||||
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
||||||
user.container_id = container_id
|
user.container_id = container_id
|
||||||
user.container_port = port
|
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()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
|
||||||
4
app.py
4
app.py
|
|
@ -3,9 +3,10 @@ from flask_login import LoginManager, login_required, current_user
|
||||||
from flask_jwt_extended import JWTManager
|
from flask_jwt_extended import JWTManager
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from models import db, User
|
from models import db, User, AdminTakeoverSession
|
||||||
from auth import auth_bp
|
from auth import auth_bp
|
||||||
from api import api_bp, check_if_token_revoked
|
from api import api_bp, check_if_token_revoked
|
||||||
|
from admin_api import admin_bp
|
||||||
from config import Config
|
from config import Config
|
||||||
from container_manager import ContainerManager
|
from container_manager import ContainerManager
|
||||||
|
|
||||||
|
|
@ -55,6 +56,7 @@ login_manager.login_message_category = 'error'
|
||||||
# Blueprints registrieren
|
# Blueprints registrieren
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
|
|
|
||||||
13
config.py
13
config.py
|
|
@ -75,6 +75,19 @@ class Config:
|
||||||
# Container-Cleanup
|
# Container-Cleanup
|
||||||
CONTAINER_IDLE_TIMEOUT = int(os.getenv('CONTAINER_IDLE_TIMEOUT', 3600)) # 1h in Sekunden
|
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):
|
class DevelopmentConfig(Config):
|
||||||
"""Konfiguration für Entwicklung"""
|
"""Konfiguration für Entwicklung"""
|
||||||
|
|
|
||||||
72
decorators.py
Normal file
72
decorators.py
Normal 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
217
email_service.py
Normal 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
|
||||||
45
frontend/src/app/admin/layout.tsx
Normal file
45
frontend/src/app/admin/layout.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
581
frontend/src/app/admin/page.tsx
Normal file
581
frontend/src/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,9 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user, logout, isLoading: authLoading } = useAuth();
|
const { user, logout, isLoading: authLoading } = useAuth();
|
||||||
|
|
@ -130,6 +132,15 @@ export default function DashboardPage() {
|
||||||
<span className="text-lg font-semibold">Container Spawner</span>
|
<span className="text-lg font-semibold">Container Spawner</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className="text-xs">
|
<AvatarFallback className="text-xs">
|
||||||
|
|
@ -137,6 +148,11 @@ export default function DashboardPage() {
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-sm font-medium">{user.username}</span>
|
<span className="text-sm font-medium">{user.username}</span>
|
||||||
|
{user.is_admin && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -14,13 +15,17 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Container, Loader2 } from "lucide-react";
|
import { Container, Loader2, Mail, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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 { login, user, isLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -34,6 +39,8 @@ export default function LoginPage() {
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
|
setNeedsVerification(false);
|
||||||
|
setEmailSent(false);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const result = await login(username, password);
|
const result = await login(username, password);
|
||||||
|
|
@ -42,10 +49,33 @@ export default function LoginPage() {
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || "Login fehlgeschlagen");
|
setError(result.error || "Login fehlgeschlagen");
|
||||||
|
if (result.needsVerification) {
|
||||||
|
setNeedsVerification(true);
|
||||||
|
}
|
||||||
setIsSubmitting(false);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
<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>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Benutzername</Label>
|
<Label htmlFor="username">Benutzername</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -90,7 +171,7 @@ export default function LoginPage() {
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="Dein Passwort"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Container, Loader2 } from "lucide-react";
|
import { Container, Loader2, Mail, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
|
|
@ -23,6 +23,8 @@ export default function SignupPage() {
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [signupSuccess, setSignupSuccess] = useState(false);
|
||||||
|
const [successMessage, setSuccessMessage] = useState("");
|
||||||
|
|
||||||
const { signup, user, isLoading } = useAuth();
|
const { signup, user, isLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -38,7 +40,7 @@ export default function SignupPage() {
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError("Passworter stimmen nicht uberein");
|
setError("Passwoerter stimmen nicht ueberein");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,12 +54,19 @@ export default function SignupPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validiere Username-Format
|
||||||
|
if (!/^[a-zA-Z0-9-]+$/.test(username)) {
|
||||||
|
setError("Benutzername darf nur Buchstaben, Zahlen und Bindestriche enthalten");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const result = await signup(username, email, password);
|
const result = await signup(username, email, password);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
router.push("/dashboard");
|
setSignupSuccess(true);
|
||||||
|
setSuccessMessage(result.message || "Registrierung erfolgreich!");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || "Registrierung fehlgeschlagen");
|
setError(result.error || "Registrierung fehlgeschlagen");
|
||||||
setIsSubmitting(false);
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
|
|
@ -103,7 +154,8 @@ export default function SignupPage() {
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Dieser wird Teil deiner Service-URL
|
Nur Buchstaben, Zahlen und Bindestriche. Wird Teil deiner
|
||||||
|
Service-URL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -117,13 +169,16 @@ export default function SignupPage() {
|
||||||
required
|
required
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Du erhaeltst eine Verifizierungs-Email an diese Adresse.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Passwort</Label>
|
<Label htmlFor="password">Passwort</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="Mindestens 6 Zeichen"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|
@ -131,11 +186,11 @@ export default function SignupPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">Passwort bestatigen</Label>
|
<Label htmlFor="confirmPassword">Passwort bestaetigen</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="Passwort wiederholen"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|
@ -146,7 +201,7 @@ export default function SignupPage() {
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Container wird erstellt...
|
Registrierung laeuft...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Registrieren"
|
"Registrieren"
|
||||||
|
|
|
||||||
69
frontend/src/app/verify-error/page.tsx
Normal file
69
frontend/src/app/verify-error/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/app/verify-success/page.tsx
Normal file
80
frontend/src/app/verify-success/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,12 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { api, LoginResponse, UserResponse } from "@/lib/api";
|
import { api, LoginResponse, UserResponse } from "@/lib/api";
|
||||||
|
|
||||||
interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
state: "registered" | "verified" | "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
|
|
@ -22,12 +24,12 @@ interface AuthContextType {
|
||||||
login: (
|
login: (
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||||
signup: (
|
signup: (
|
||||||
username: string,
|
username: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +60,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const { data, error } = await api.getUser();
|
const { data, error } = await api.getUser();
|
||||||
if (data && !error) {
|
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 {
|
} else {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
|
@ -70,16 +78,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const login = async (
|
const login = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> => {
|
||||||
const { data, error } = await api.login(username, password);
|
const { data, error } = await api.login(username, password);
|
||||||
|
|
||||||
if (error || !data) {
|
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);
|
localStorage.setItem("token", data.access_token);
|
||||||
setToken(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 };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -87,17 +107,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
username: string,
|
username: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
): Promise<{ success: boolean; error?: string; message?: string }> => {
|
||||||
const { data, error } = await api.signup(username, email, password);
|
const { data, error } = await api.signup(username, email, password);
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return { success: false, error: error || "Registrierung fehlgeschlagen" };
|
return { success: false, error: error || "Registrierung fehlgeschlagen" };
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("token", data.access_token);
|
// Nach Signup wird kein Token mehr zurueckgegeben
|
||||||
setToken(data.access_token);
|
// User muss erst Email verifizieren
|
||||||
setUser(data.user);
|
return {
|
||||||
return { success: true };
|
success: true,
|
||||||
|
message: data.message
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ async function fetchApi<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Auth Interfaces
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
|
@ -47,14 +51,30 @@ export interface LoginResponse {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: 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 {
|
export interface UserResponse {
|
||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
state: "registered" | "verified" | "active";
|
||||||
|
last_used: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
};
|
};
|
||||||
container: {
|
container: {
|
||||||
|
|
@ -76,7 +96,68 @@ export interface ContainerRestartResponse {
|
||||||
status: string;
|
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 = {
|
export const api = {
|
||||||
|
// Auth
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
fetchApi<LoginResponse>("/api/auth/login", {
|
fetchApi<LoginResponse>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -84,7 +165,7 @@ export const api = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
signup: (username: string, email: string, password: string) =>
|
signup: (username: string, email: string, password: string) =>
|
||||||
fetchApi<LoginResponse>("/api/auth/signup", {
|
fetchApi<SignupResponse>("/api/auth/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, email, password }),
|
body: JSON.stringify({ username, email, password }),
|
||||||
}),
|
}),
|
||||||
|
|
@ -94,8 +175,19 @@ export const api = {
|
||||||
method: "POST",
|
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"),
|
getUser: () => fetchApi<UserResponse>("/api/user/me"),
|
||||||
|
|
||||||
|
// Container
|
||||||
getContainerStatus: () =>
|
getContainerStatus: () =>
|
||||||
fetchApi<ContainerStatusResponse>("/api/container/status"),
|
fetchApi<ContainerStatusResponse>("/api/container/status"),
|
||||||
|
|
||||||
|
|
@ -104,3 +196,69 @@ export const api = {
|
||||||
method: "POST",
|
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"),
|
||||||
|
};
|
||||||
|
|
|
||||||
63
models.py
63
models.py
|
|
@ -2,9 +2,19 @@ from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
db = SQLAlchemy()
|
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):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
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_id = db.Column(db.String(100), nullable=True)
|
||||||
container_port = db.Column(db.Integer, nullable=True)
|
container_port = db.Column(db.Integer, nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
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):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
def check_password(self, 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])
|
||||||
Loading…
Reference in New Issue
Block a user