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_IDLE_TIMEOUT=3600
|
||||
|
||||
# ============================================================
|
||||
# EMAIL - Verifizierung und Benachrichtigungen
|
||||
# ============================================================
|
||||
|
||||
# SMTP-Server Konfiguration
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASSWORD=your-smtp-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Frontend-URL fuer Email-Links (Verifizierung etc.)
|
||||
# WICHTIG: Muss die URL sein, unter der das Frontend erreichbar ist
|
||||
FRONTEND_URL=https://coder.example.com
|
||||
|
||||
# ============================================================
|
||||
# PRODUKTION - Erweiterte Einstellungen
|
||||
# ============================================================
|
||||
|
|
|
|||
363
admin_api.py
Normal file
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 (
|
||||
create_access_token,
|
||||
jwt_required,
|
||||
get_jwt_identity,
|
||||
get_jwt
|
||||
)
|
||||
from datetime import timedelta
|
||||
from models import db, User
|
||||
from datetime import timedelta, datetime
|
||||
from models import db, User, UserState
|
||||
from container_manager import ContainerManager
|
||||
from email_service import generate_verification_token, send_verification_email
|
||||
from config import Config
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
|
@ -17,11 +19,11 @@ token_blacklist = set()
|
|||
|
||||
@api_bp.route('/auth/login', methods=['POST'])
|
||||
def api_login():
|
||||
"""API-Login - gibt JWT-Token zurück"""
|
||||
"""API-Login - gibt JWT-Token zurueck"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten übermittelt'}), 400
|
||||
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
||||
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
|
@ -32,7 +34,18 @@ def api_login():
|
|||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
return jsonify({'error': 'Ungültige Anmeldedaten'}), 401
|
||||
return jsonify({'error': 'Ungueltige Anmeldedaten'}), 401
|
||||
|
||||
# Blockade-Check
|
||||
if user.is_blocked:
|
||||
return jsonify({'error': 'Konto gesperrt. Kontaktiere einen Administrator.'}), 403
|
||||
|
||||
# Verifizierungs-Check
|
||||
if user.state == UserState.REGISTERED.value:
|
||||
return jsonify({
|
||||
'error': 'Email nicht verifiziert. Bitte pruefe dein Postfach.',
|
||||
'needs_verification': True
|
||||
}), 403
|
||||
|
||||
# Container spawnen wenn noch nicht vorhanden
|
||||
if not user.container_id:
|
||||
|
|
@ -41,17 +54,25 @@ def api_login():
|
|||
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
||||
user.container_id = container_id
|
||||
user.container_port = port
|
||||
# State auf ACTIVE setzen bei erstem Container-Start
|
||||
if user.state == UserState.VERIFIED.value:
|
||||
user.state = UserState.ACTIVE.value
|
||||
user.last_used = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}")
|
||||
return jsonify({'error': f'Container-Start fehlgeschlagen: {str(e)}'}), 500
|
||||
else:
|
||||
# last_used aktualisieren
|
||||
user.last_used = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# JWT-Token erstellen
|
||||
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
||||
access_token = create_access_token(
|
||||
identity=str(user.id),
|
||||
expires_delta=expires,
|
||||
additional_claims={'username': user.username}
|
||||
additional_claims={'username': user.username, 'is_admin': user.is_admin}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
|
|
@ -61,18 +82,20 @@ def api_login():
|
|||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
'email': user.email,
|
||||
'is_admin': user.is_admin,
|
||||
'state': user.state
|
||||
}
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/signup', methods=['POST'])
|
||||
def api_signup():
|
||||
"""API-Registrierung - erstellt User, spawnt Container, gibt JWT zurück"""
|
||||
"""API-Registrierung - erstellt User und sendet Verifizierungs-Email"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten übermittelt'}), 400
|
||||
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
||||
|
||||
username = data.get('username')
|
||||
email = data.get('email')
|
||||
|
|
@ -88,49 +111,53 @@ def api_signup():
|
|||
if len(password) < 6:
|
||||
return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400
|
||||
|
||||
# Prüfe ob User existiert
|
||||
# Username-Validierung (nur alphanumerisch und Bindestrich)
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9-]+$', username):
|
||||
return jsonify({'error': 'Username darf nur Buchstaben, Zahlen und Bindestriche enthalten'}), 400
|
||||
|
||||
# Pruefe ob User existiert
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({'error': 'Username bereits vergeben'}), 409
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
return jsonify({'error': 'Email bereits registriert'}), 409
|
||||
|
||||
# Pruefe ob dies der erste User ist -> wird Admin
|
||||
is_first_user = User.query.count() == 0
|
||||
|
||||
# Neuen User anlegen
|
||||
user = User(username=username, email=email)
|
||||
user.set_password(password)
|
||||
user.is_admin = is_first_user
|
||||
user.state = UserState.REGISTERED.value
|
||||
user.verification_token = generate_verification_token()
|
||||
user.verification_sent_at = datetime.utcnow()
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Container spawnen
|
||||
try:
|
||||
container_mgr = ContainerManager()
|
||||
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
||||
user.container_id = container_id
|
||||
user.container_port = port
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
current_app.logger.error(f"Registrierung fehlgeschlagen: {str(e)}")
|
||||
return jsonify({'error': f'Container-Erstellung fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# JWT-Token erstellen
|
||||
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
||||
access_token = create_access_token(
|
||||
identity=str(user.id),
|
||||
expires_delta=expires,
|
||||
additional_claims={'username': user.username}
|
||||
# Verifizierungs-Email senden
|
||||
frontend_url = Config.FRONTEND_URL
|
||||
email_sent = send_verification_email(
|
||||
user.email,
|
||||
user.username,
|
||||
user.verification_token,
|
||||
frontend_url
|
||||
)
|
||||
|
||||
if not email_sent:
|
||||
current_app.logger.warning(f"Verifizierungs-Email konnte nicht gesendet werden an {user.email}")
|
||||
|
||||
return jsonify({
|
||||
'access_token': access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(expires.total_seconds()),
|
||||
'message': 'Registrierung erfolgreich. Bitte pruefe dein Postfach und bestatige deine Email-Adresse.',
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
'email': user.email,
|
||||
'is_admin': user.is_admin
|
||||
},
|
||||
'email_sent': email_sent
|
||||
}), 201
|
||||
|
||||
|
||||
|
|
@ -143,10 +170,75 @@ def api_logout():
|
|||
return jsonify({'message': 'Erfolgreich abgemeldet'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/verify', methods=['GET'])
|
||||
def api_verify_email():
|
||||
"""Email-Verifizierung ueber Token-Link"""
|
||||
token = request.args.get('token')
|
||||
frontend_url = Config.FRONTEND_URL
|
||||
|
||||
if not token:
|
||||
return redirect(f"{frontend_url}/verify-error?reason=missing_token")
|
||||
|
||||
user = User.query.filter_by(verification_token=token).first()
|
||||
|
||||
if not user:
|
||||
return redirect(f"{frontend_url}/verify-error?reason=invalid_token")
|
||||
|
||||
# Token invalidieren und Status aktualisieren
|
||||
user.verification_token = None
|
||||
user.state = UserState.VERIFIED.value
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"User {user.username} hat Email verifiziert")
|
||||
return redirect(f"{frontend_url}/verify-success")
|
||||
|
||||
|
||||
@api_bp.route('/auth/resend-verification', methods=['POST'])
|
||||
def api_resend_verification():
|
||||
"""Sendet Verifizierungs-Email erneut"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
||||
|
||||
email = data.get('email')
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'Email erforderlich'}), 400
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
|
||||
if not user:
|
||||
# Aus Sicherheitsgruenden kein Fehler wenn User nicht existiert
|
||||
return jsonify({'message': 'Falls die Email registriert ist, wurde eine neue Verifizierungs-Email gesendet.'}), 200
|
||||
|
||||
if user.state != UserState.REGISTERED.value:
|
||||
return jsonify({'error': 'Email bereits verifiziert'}), 400
|
||||
|
||||
# Neuen Token generieren
|
||||
user.verification_token = generate_verification_token()
|
||||
user.verification_sent_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Email senden
|
||||
frontend_url = Config.FRONTEND_URL
|
||||
email_sent = send_verification_email(
|
||||
user.email,
|
||||
user.username,
|
||||
user.verification_token,
|
||||
frontend_url
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'message': 'Falls die Email registriert ist, wurde eine neue Verifizierungs-Email gesendet.',
|
||||
'email_sent': email_sent
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/user/me', methods=['GET'])
|
||||
@jwt_required()
|
||||
def api_user_me():
|
||||
"""Gibt aktuellen User und Container-Info zurück"""
|
||||
"""Gibt aktuellen User und Container-Info zurueck"""
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(int(user_id))
|
||||
|
||||
|
|
@ -172,6 +264,9 @@ def api_user_me():
|
|||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'is_admin': user.is_admin,
|
||||
'state': user.state,
|
||||
'last_used': user.last_used.isoformat() if user.last_used else None,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None
|
||||
},
|
||||
'container': {
|
||||
|
|
@ -232,6 +327,13 @@ def api_container_restart():
|
|||
container_id, port = container_mgr.spawn_container(user.id, user.username)
|
||||
user.container_id = container_id
|
||||
user.container_port = port
|
||||
|
||||
# State auf ACTIVE setzen bei Container-Start (falls noch VERIFIED)
|
||||
if user.state == UserState.VERIFIED.value:
|
||||
user.state = UserState.ACTIVE.value
|
||||
|
||||
# last_used aktualisieren
|
||||
user.last_used = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
|
|
|
|||
4
app.py
4
app.py
|
|
@ -3,9 +3,10 @@ from flask_login import LoginManager, login_required, current_user
|
|||
from flask_jwt_extended import JWTManager
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import text
|
||||
from models import db, User
|
||||
from models import db, User, AdminTakeoverSession
|
||||
from auth import auth_bp
|
||||
from api import api_bp, check_if_token_revoked
|
||||
from admin_api import admin_bp
|
||||
from config import Config
|
||||
from container_manager import ContainerManager
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ login_manager.login_message_category = 'error'
|
|||
# Blueprints registrieren
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
|
|
|||
13
config.py
13
config.py
|
|
@ -75,6 +75,19 @@ class Config:
|
|||
# Container-Cleanup
|
||||
CONTAINER_IDLE_TIMEOUT = int(os.getenv('CONTAINER_IDLE_TIMEOUT', 3600)) # 1h in Sekunden
|
||||
|
||||
# ========================================
|
||||
# SMTP / Email-Konfiguration
|
||||
# ========================================
|
||||
SMTP_HOST = os.getenv('SMTP_HOST', 'localhost')
|
||||
SMTP_PORT = int(os.getenv('SMTP_PORT', 587))
|
||||
SMTP_USER = os.getenv('SMTP_USER', '')
|
||||
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', '')
|
||||
SMTP_FROM = os.getenv('SMTP_FROM', 'noreply@localhost')
|
||||
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true'
|
||||
|
||||
# Frontend-URL fuer Email-Links
|
||||
FRONTEND_URL = os.getenv('FRONTEND_URL', f"http://localhost:3000")
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Konfiguration für Entwicklung"""
|
||||
|
|
|
|||
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,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, logout, isLoading: authLoading } = useAuth();
|
||||
|
|
@ -130,6 +132,15 @@ export default function DashboardPage() {
|
|||
<span className="text-lg font-semibold">Container Spawner</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Admin-Link */}
|
||||
{user.is_admin && (
|
||||
<Link href="/admin">
|
||||
<Button variant="outline" size="sm">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
|
|
@ -137,6 +148,11 @@ export default function DashboardPage() {
|
|||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -14,13 +15,17 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Container, Loader2 } from "lucide-react";
|
||||
import { Container, Loader2, Mail, AlertCircle } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [resendingEmail, setResendingEmail] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
|
||||
const { login, user, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
|
@ -34,6 +39,8 @@ export default function LoginPage() {
|
|||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setNeedsVerification(false);
|
||||
setEmailSent(false);
|
||||
setIsSubmitting(true);
|
||||
|
||||
const result = await login(username, password);
|
||||
|
|
@ -42,10 +49,33 @@ export default function LoginPage() {
|
|||
router.push("/dashboard");
|
||||
} else {
|
||||
setError(result.error || "Login fehlgeschlagen");
|
||||
if (result.needsVerification) {
|
||||
setNeedsVerification(true);
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!email) {
|
||||
setError("Bitte gib deine Email-Adresse ein");
|
||||
return;
|
||||
}
|
||||
|
||||
setResendingEmail(true);
|
||||
setError("");
|
||||
|
||||
const { data, error } = await api.resendVerification(email);
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
} else {
|
||||
setEmailSent(true);
|
||||
}
|
||||
|
||||
setResendingEmail(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
|
|
@ -70,9 +100,60 @@ export default function LoginPage() {
|
|||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verifizierungs-Hinweis */}
|
||||
{needsVerification && (
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="mt-0.5 h-5 w-5 text-yellow-600" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-800">
|
||||
Email nicht verifiziert
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Bitte pruefe dein Postfach und klicke auf den
|
||||
Verifizierungs-Link. Falls du keine Email erhalten hast,
|
||||
kannst du eine neue anfordern.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Deine Email-Adresse"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="bg-white"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendingEmail || emailSent}
|
||||
className="w-full"
|
||||
>
|
||||
{resendingEmail ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Wird gesendet...
|
||||
</>
|
||||
) : emailSent ? (
|
||||
"Email gesendet!"
|
||||
) : (
|
||||
"Neue Verifizierungs-Email senden"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Benutzername</Label>
|
||||
<Input
|
||||
|
|
@ -90,7 +171,7 @@ export default function LoginPage() {
|
|||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
placeholder="Dein Passwort"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Container, Loader2 } from "lucide-react";
|
||||
import { Container, Loader2, Mail, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
|
|
@ -23,6 +23,8 @@ export default function SignupPage() {
|
|||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [signupSuccess, setSignupSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
const { signup, user, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
|
@ -38,7 +40,7 @@ export default function SignupPage() {
|
|||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passworter stimmen nicht uberein");
|
||||
setError("Passwoerter stimmen nicht ueberein");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -52,12 +54,19 @@ export default function SignupPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validiere Username-Format
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(username)) {
|
||||
setError("Benutzername darf nur Buchstaben, Zahlen und Bindestriche enthalten");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const result = await signup(username, email, password);
|
||||
|
||||
if (result.success) {
|
||||
router.push("/dashboard");
|
||||
setSignupSuccess(true);
|
||||
setSuccessMessage(result.message || "Registrierung erfolgreich!");
|
||||
} else {
|
||||
setError(result.error || "Registrierung fehlgeschlagen");
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -72,6 +81,48 @@ export default function SignupPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Erfolgsanzeige nach Registrierung
|
||||
if (signupSuccess) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Registrierung erfolgreich!
|
||||
</CardTitle>
|
||||
<CardDescription>{successMessage}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="mt-0.5 h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium">Pruefe dein Postfach</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Wir haben eine Verifizierungs-Email an{" "}
|
||||
<strong>{email}</strong> gesendet. Klicke auf den Link in
|
||||
der Email, um dein Konto zu aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>Keine Email erhalten?</p>
|
||||
<p>Pruefe deinen Spam-Ordner oder versuche dich anzumelden,</p>
|
||||
<p>um eine neue Verifizierungs-Email anzufordern.</p>
|
||||
</div>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/login">Zum Login</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
|
|
@ -103,7 +154,8 @@ export default function SignupPage() {
|
|||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dieser wird Teil deiner Service-URL
|
||||
Nur Buchstaben, Zahlen und Bindestriche. Wird Teil deiner
|
||||
Service-URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -117,13 +169,16 @@ export default function SignupPage() {
|
|||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Du erhaeltst eine Verifizierungs-Email an diese Adresse.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
|
|
@ -131,11 +186,11 @@ export default function SignupPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Passwort bestatigen</Label>
|
||||
<Label htmlFor="confirmPassword">Passwort bestaetigen</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
placeholder="Passwort wiederholen"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
|
|
@ -146,7 +201,7 @@ export default function SignupPage() {
|
|||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Container wird erstellt...
|
||||
Registrierung laeuft...
|
||||
</>
|
||||
) : (
|
||||
"Registrieren"
|
||||
|
|
|
|||
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";
|
||||
import { api, LoginResponse, UserResponse } from "@/lib/api";
|
||||
|
||||
interface User {
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
state: "registered" | "verified" | "active";
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
|
|
@ -22,12 +24,12 @@ interface AuthContextType {
|
|||
login: (
|
||||
username: string,
|
||||
password: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||
signup: (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
|
@ -58,7 +60,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const { data, error } = await api.getUser();
|
||||
if (data && !error) {
|
||||
setUser(data.user);
|
||||
setUser({
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
email: data.user.email,
|
||||
is_admin: data.user.is_admin,
|
||||
state: data.user.state,
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
|
|
@ -70,16 +78,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const login = async (
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> => {
|
||||
const { data, error } = await api.login(username, password);
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: error || "Login fehlgeschlagen" };
|
||||
// Pruefe ob Verifizierung erforderlich
|
||||
const needsVerification = error?.includes("nicht verifiziert");
|
||||
return {
|
||||
success: false,
|
||||
error: error || "Login fehlgeschlagen",
|
||||
needsVerification
|
||||
};
|
||||
}
|
||||
|
||||
localStorage.setItem("token", data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser(data.user);
|
||||
setUser({
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
email: data.user.email,
|
||||
is_admin: data.user.is_admin,
|
||||
state: data.user.state,
|
||||
});
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
|
|
@ -87,17 +107,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
): Promise<{ success: boolean; error?: string; message?: string }> => {
|
||||
const { data, error } = await api.signup(username, email, password);
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: error || "Registrierung fehlgeschlagen" };
|
||||
}
|
||||
|
||||
localStorage.setItem("token", data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
// Nach Signup wird kein Token mehr zurueckgegeben
|
||||
// User muss erst Email verifizieren
|
||||
return {
|
||||
success: true,
|
||||
message: data.message
|
||||
};
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ async function fetchApi<T>(
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auth Interfaces
|
||||
// ============================================================
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
|
|
@ -47,14 +51,30 @@ export interface LoginResponse {
|
|||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
state: "registered" | "verified" | "active";
|
||||
};
|
||||
}
|
||||
|
||||
export interface SignupResponse {
|
||||
message: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
email_sent: boolean;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
state: "registered" | "verified" | "active";
|
||||
last_used: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
container: {
|
||||
|
|
@ -76,7 +96,68 @@ export interface ContainerRestartResponse {
|
|||
status: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Admin Interfaces
|
||||
// ============================================================
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
is_blocked: boolean;
|
||||
blocked_at: string | null;
|
||||
state: "registered" | "verified" | "active";
|
||||
last_used: string | null;
|
||||
created_at: string | null;
|
||||
container_id: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AdminUserResponse {
|
||||
user: AdminUser & {
|
||||
container_status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminActionResponse {
|
||||
message: string;
|
||||
user?: AdminUser;
|
||||
email_sent?: boolean;
|
||||
}
|
||||
|
||||
export interface TakeoverResponse {
|
||||
message: string;
|
||||
session_id: number;
|
||||
status: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TakeoverSession {
|
||||
id: number;
|
||||
admin_id: number;
|
||||
admin_username: string | null;
|
||||
target_user_id: number;
|
||||
target_username: string | null;
|
||||
started_at: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveTakeoversResponse {
|
||||
sessions: TakeoverSession[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions
|
||||
// ============================================================
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (username: string, password: string) =>
|
||||
fetchApi<LoginResponse>("/api/auth/login", {
|
||||
method: "POST",
|
||||
|
|
@ -84,7 +165,7 @@ export const api = {
|
|||
}),
|
||||
|
||||
signup: (username: string, email: string, password: string) =>
|
||||
fetchApi<LoginResponse>("/api/auth/signup", {
|
||||
fetchApi<SignupResponse>("/api/auth/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
}),
|
||||
|
|
@ -94,8 +175,19 @@ export const api = {
|
|||
method: "POST",
|
||||
}),
|
||||
|
||||
resendVerification: (email: string) =>
|
||||
fetchApi<{ message: string; email_sent: boolean }>(
|
||||
"/api/auth/resend-verification",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}
|
||||
),
|
||||
|
||||
// User
|
||||
getUser: () => fetchApi<UserResponse>("/api/user/me"),
|
||||
|
||||
// Container
|
||||
getContainerStatus: () =>
|
||||
fetchApi<ContainerStatusResponse>("/api/container/status"),
|
||||
|
||||
|
|
@ -104,3 +196,69 @@ export const api = {
|
|||
method: "POST",
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Admin API Functions
|
||||
// ============================================================
|
||||
|
||||
export const adminApi = {
|
||||
// Users
|
||||
getUsers: () => fetchApi<AdminUsersResponse>("/api/admin/users"),
|
||||
|
||||
getUser: (id: number) =>
|
||||
fetchApi<AdminUserResponse>(`/api/admin/users/${id}`),
|
||||
|
||||
// Block/Unblock
|
||||
blockUser: (id: number) =>
|
||||
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/block`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
unblockUser: (id: number) =>
|
||||
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/unblock`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
// Password Reset
|
||||
resetPassword: (id: number, password?: string) =>
|
||||
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/reset-password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(password ? { password } : {}),
|
||||
}),
|
||||
|
||||
// Verification
|
||||
resendVerification: (id: number) =>
|
||||
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/resend-verification`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
// Container
|
||||
deleteUserContainer: (id: number) =>
|
||||
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/container`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
// Delete User
|
||||
deleteUser: (id: number) =>
|
||||
fetchApi<AdminActionResponse>(`/api/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
// Takeover (Phase 2 - Dummy)
|
||||
startTakeover: (id: number, reason?: string) =>
|
||||
fetchApi<TakeoverResponse>(`/api/admin/users/${id}/takeover`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason || "" }),
|
||||
}),
|
||||
|
||||
endTakeover: (sessionId: number) =>
|
||||
fetchApi<{ message: string; session_id: number }>(
|
||||
`/api/admin/takeover/${sessionId}/end`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
),
|
||||
|
||||
getActiveTakeovers: () =>
|
||||
fetchApi<ActiveTakeoversResponse>("/api/admin/takeover/active"),
|
||||
};
|
||||
|
|
|
|||
57
models.py
57
models.py
|
|
@ -2,9 +2,19 @@ from flask_sqlalchemy import SQLAlchemy
|
|||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class UserState(Enum):
|
||||
"""Benutzer-Status fuer Email-Verifizierung und Aktivitaet"""
|
||||
REGISTERED = 'registered' # Signup abgeschlossen, Email nicht verifiziert
|
||||
VERIFIED = 'verified' # Email verifiziert, Container noch nie genutzt
|
||||
ACTIVE = 'active' # Container mindestens einmal gestartet
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
|
|
@ -14,8 +24,55 @@ class User(UserMixin, db.Model):
|
|||
container_port = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Admin-Felder
|
||||
is_admin = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Sperr-Felder
|
||||
is_blocked = db.Column(db.Boolean, default=False, nullable=False)
|
||||
blocked_at = db.Column(db.DateTime, nullable=True)
|
||||
blocked_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Email-Verifizierung und Status
|
||||
state = db.Column(db.String(20), default=UserState.REGISTERED.value, nullable=False)
|
||||
verification_token = db.Column(db.String(64), nullable=True)
|
||||
verification_sent_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Aktivitaetstracking
|
||||
last_used = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Beziehung fuer blocked_by
|
||||
blocker = db.relationship('User', remote_side=[id], foreign_keys=[blocked_by])
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
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