fixed client login and changed qt6 lib

This commit is contained in:
duffyduck 2026-02-02 12:48:16 +01:00
parent 6901dc369b
commit 1de7f5b593
13 changed files with 755 additions and 21 deletions

38
.env.example Normal file
View File

@ -0,0 +1,38 @@
# ==============================================
# mGuard VPN Endpoint Server Configuration
# ==============================================
# Copy this file to .env and adjust the values
# Database Configuration
DB_ROOT_PASSWORD=change_me_root_password
DB_NAME=mguard_vpn
DB_USER=mguard
DB_PASSWORD=change_me_db_password
# API/Web Configuration
# Generate with: openssl rand -hex 32
SECRET_KEY=change_me_generate_with_openssl_rand_hex_32
# Admin User (created on first startup)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_admin_password
ADMIN_EMAIL=admin@example.com
# ==============================================
# OpenVPN Container
# ==============================================
# Der OpenVPN-Container läuft im Host-Netzwerk-Modus und verwaltet
# automatisch alle VPN-Server-Instanzen, die in der Datenbank als
# aktiv markiert sind.
#
# Workflow:
# 1. Container starten mit: docker-compose up -d
# 2. Im Browser: CA erstellen unter http://localhost:8000/ca/new
# 3. VPN-Server erstellen unter http://localhost:8000/vpn-servers/new
# 4. Der Container erkennt neue Server automatisch (Polling alle 30s)
#
# Keine Anpassungen in docker-compose.yml nötig!
# VPN-Server können jederzeit über die Web-UI hinzugefügt/entfernt werden.
#
# Optional: Polling-Intervall anpassen (Standard: 30 Sekunden)
# POLL_INTERVAL=30

129
.gitignore vendored Normal file
View File

@ -0,0 +1,129 @@
# Environment and credentials
.env
.env.*
!.env.example
*.env
secrets/
credentials/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
ENV/
env/
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.project
.pydevproject
.settings/
*.sublime-project
*.sublime-workspace
# Database
*.db
*.sqlite
*.sqlite3
data/
db_data/
# Docker volumes (local data)
mariadb_data/
openvpn_data/
openvpn_logs/
# Logs
*.log
logs/
# Certificates and keys (sensitive!)
*.pem
*.key
*.crt
*.csr
*.p12
*.pfx
ca/
certs/
keys/
pki/
easy-rsa/
# OpenVPN specific
*.ovpn
ta.key
dh*.pem
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Test and coverage
.tox/
.nox/
.coverage
.coverage.*
htmlcov/
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
# Jupyter
.ipynb_checkpoints/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Temporary files
tmp/
temp/
*.tmp
*.temp
*.bak
*.backup
# Build artifacts
*.tar.gz
*.zip
# Node (if frontend exists)
node_modules/
npm-debug.log
yarn-error.log

242
README.md
View File

@ -531,6 +531,248 @@ docker-compose up -d
--- ---
## Desktop Client (Techniker-Anwendung)
Der Desktop Client ist eine PyQt6-Anwendung für Techniker, um sich zu VPN-Gateways zu verbinden.
### Voraussetzungen
- **Python 3.10+**
- **OpenVPN** (muss installiert sein)
- Zugang zum mGuard VPN Server
---
### Installation
#### Linux (Debian/Ubuntu)
```bash
# System-Abhängigkeiten installieren
sudo apt update
sudo apt install python3 python3-pip python3-venv openvpn
# In das Client-Verzeichnis wechseln
cd client/
# Virtuelle Umgebung erstellen (empfohlen)
python3 -m venv venv
source venv/bin/activate
# Abhängigkeiten installieren
pip install -r requirements.txt
```
#### Linux (Fedora/RHEL)
```bash
# System-Abhängigkeiten installieren
sudo dnf install python3 python3-pip openvpn
# In das Client-Verzeichnis wechseln
cd client/
# Virtuelle Umgebung erstellen (empfohlen)
python3 -m venv venv
source venv/bin/activate
# Abhängigkeiten installieren
pip install -r requirements.txt
```
#### Linux (Arch)
```bash
# System-Abhängigkeiten installieren
sudo pacman -S python python-pip openvpn
# In das Client-Verzeichnis wechseln
cd client/
# Virtuelle Umgebung erstellen (empfohlen)
python -m venv venv
source venv/bin/activate
# Abhängigkeiten installieren
pip install -r requirements.txt
```
#### Windows
1. **Python installieren:**
- Download von https://www.python.org/downloads/
- Bei Installation "Add Python to PATH" aktivieren
2. **OpenVPN installieren:**
- Download von https://openvpn.net/community-downloads/
- Standard-Installation in `C:\Program Files\OpenVPN`
3. **Client installieren:**
```powershell
# PowerShell oder CMD
cd client
# Virtuelle Umgebung erstellen (empfohlen)
python -m venv venv
venv\Scripts\activate
# Abhängigkeiten installieren
pip install -r requirements.txt
```
#### macOS
```bash
# Homebrew installieren (falls nicht vorhanden)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Python und OpenVPN installieren
brew install python openvpn
# In das Client-Verzeichnis wechseln
cd client/
# Virtuelle Umgebung erstellen (empfohlen)
python3 -m venv venv
source venv/bin/activate
# Abhängigkeiten installieren
pip install -r requirements.txt
```
---
### Client starten
#### Linux / macOS
```bash
# In das Client-Verzeichnis wechseln
cd client/
# Virtuelle Umgebung aktivieren (falls verwendet)
source venv/bin/activate
# Client starten
python main.py
```
**Hinweis:** Für VPN-Verbindungen sind Root-Rechte erforderlich:
```bash
# Mit sudo starten (für VPN-Verbindungen)
sudo venv/bin/python main.py
```
#### Windows
```powershell
# In das Client-Verzeichnis wechseln
cd client
# Virtuelle Umgebung aktivieren (falls verwendet)
venv\Scripts\activate
# Client starten
python main.py
```
**Hinweis:** Als Administrator ausführen für VPN-Verbindungen.
---
### Erste Schritte im Client
1. **Server konfigurieren:**
- Beim ersten Start Server-URL eingeben (z.B. `https://vpn.meinefirma.de:8000`)
2. **Anmelden:**
- Mit Techniker-Zugangsdaten einloggen
3. **Gateway wählen:**
- Liste der zugewiesenen Gateways wird angezeigt
- Online-Status wird angezeigt (grün = erreichbar)
4. **Verbinden:**
- Gateway auswählen und "Verbinden" klicken
- VPN-Verbindung wird automatisch hergestellt
5. **Endpunkte nutzen:**
- Nach Verbindung sind die Endpunkte (SPS, HMI, etc.) erreichbar
- IP-Adressen und Ports werden angezeigt
---
### Konfiguration
Der Client speichert Einstellungen in:
| OS | Pfad |
|----|------|
| Linux/macOS | `~/.mguard-vpn/settings.json` |
| Windows | `C:\Users\<User>\.mguard-vpn\settings.json` |
OpenVPN-Konfigurationen werden gespeichert in:
| OS | Pfad |
|----|------|
| Linux/macOS | `~/.openvpn/` |
| Windows | `C:\Users\<User>\OpenVPN\config\` |
---
### Fehlerbehebung Client
#### "OpenVPN nicht gefunden"
**Linux:**
```bash
# OpenVPN Pfad prüfen
which openvpn
# Sollte /usr/sbin/openvpn ausgeben
# Falls woanders installiert, symlink erstellen
sudo ln -s /pfad/zu/openvpn /usr/sbin/openvpn
```
**Windows:**
- Prüfen ob OpenVPN in `C:\Program Files\OpenVPN\bin\` installiert ist
- Falls anderer Pfad: `client/config.py` anpassen
**macOS:**
```bash
# OpenVPN Pfad prüfen
which openvpn
# Meist /opt/homebrew/bin/openvpn oder /usr/local/bin/openvpn
# Symlink erstellen falls nötig
sudo ln -s $(which openvpn) /usr/sbin/openvpn
```
#### "Permission denied" bei VPN-Verbindung
VPN-Verbindungen erfordern erhöhte Rechte:
```bash
# Linux/macOS: Mit sudo starten
sudo python main.py
# Windows: Als Administrator ausführen
# Rechtsklick → "Als Administrator ausführen"
```
#### PyQt6 Fehler auf Linux
```bash
# Fehlende Qt-Bibliotheken installieren
sudo apt install libxcb-xinerama0 libxkbcommon-x11-0
# Oder für Wayland
sudo apt install qtwayland5
```
---
## Lizenz ## Lizenz
Proprietär - Nur für internen Gebrauch. Proprietär - Nur für internen Gebrauch.

View File

@ -1,5 +1,6 @@
# PyQt GUI # PyQt GUI
PyQt6==6.6.1 PyQt6>=6.4.0,<6.7.0
PyQt6-Qt6>=6.4.0,<6.7.0
# HTTP Client # HTTP Client
httpx==0.26.0 httpx==0.26.0

View File

@ -37,7 +37,7 @@ class APIClient:
self.base_url = base_url.rstrip('/') self.base_url = base_url.rstrip('/')
self.access_token: Optional[str] = None self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None self.refresh_token: Optional[str] = None
self.client = httpx.Client(timeout=30.0) self.client = httpx.Client(timeout=30.0, follow_redirects=True)
def _headers(self) -> dict: def _headers(self) -> dict:
"""Get request headers with auth token.""" """Get request headers with auth token."""
@ -64,8 +64,10 @@ class APIClient:
data = response.json() data = response.json()
self.access_token = data["access_token"] self.access_token = data["access_token"]
self.refresh_token = data["refresh_token"] self.refresh_token = data["refresh_token"]
print(f"Login successful, token: {self.access_token[:50]}...")
return True return True
except httpx.HTTPError: except httpx.HTTPError as e:
print(f"Login error: {e}")
return False return False
def logout(self): def logout(self):
@ -100,7 +102,8 @@ class APIClient:
def get_gateways(self) -> list[Gateway]: def get_gateways(self) -> list[Gateway]:
"""Get list of accessible gateways.""" """Get list of accessible gateways."""
try: try:
data = self._request("GET", "/api/gateways") print(f"Fetching gateways with token: {self.access_token[:30] if self.access_token else 'NONE'}...")
data = self._request("GET", "/api/gateways/")
return [ return [
Gateway( Gateway(
id=g["id"], id=g["id"],
@ -113,7 +116,8 @@ class APIClient:
) )
for g in data for g in data
] ]
except httpx.HTTPError: except httpx.HTTPError as e:
print(f"Error fetching gateways: {e}")
return [] return []
def get_gateways_status(self) -> list[dict]: def get_gateways_status(self) -> list[dict]:

View File

@ -33,7 +33,7 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"}
) )
user_id = payload.get("sub") user_id = int(payload.get("sub")) # sub is stored as string per JWT spec
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:

View File

@ -100,7 +100,8 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
docs_url="/api/docs", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
openapi_url="/api/openapi.json" openapi_url="/api/openapi.json",
redirect_slashes=False # Prevent 307 redirects that lose auth headers
) )
# Session middleware for web UI # Session middleware for web UI

View File

@ -18,8 +18,8 @@ class GatewayBase(BaseModel):
@field_validator('vpn_subnet') @field_validator('vpn_subnet')
@classmethod @classmethod
def validate_subnet(cls, v: str | None) -> str | None: def validate_subnet(cls, v: str | None) -> str | None:
if v is None: if v is None or v == "" or v == "None":
return v return None
try: try:
ipaddress.ip_network(v, strict=False) ipaddress.ip_network(v, strict=False)
return v return v
@ -46,8 +46,8 @@ class GatewayUpdate(BaseModel):
@field_validator('vpn_subnet') @field_validator('vpn_subnet')
@classmethod @classmethod
def validate_subnet(cls, v: str | None) -> str | None: def validate_subnet(cls, v: str | None) -> str | None:
if v is None: if v is None or v == "" or v == "None":
return v return None
try: try:
ipaddress.ip_network(v, strict=False) ipaddress.ip_network(v, strict=False)
return v return v

View File

@ -6,7 +6,7 @@
<nav aria-label="breadcrumb" class="mb-3"> <nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/users">Benutzer</a></li> <li class="breadcrumb-item"><a href="/users">Benutzer</a></li>
<li class="breadcrumb-item"><a href="/users/{{ user.id }}/edit">{{ user.username }}</a></li> <li class="breadcrumb-item"><a href="/users/{{ user.id }}">{{ user.username }}</a></li>
<li class="breadcrumb-item active">Zugriffe</li> <li class="breadcrumb-item active">Zugriffe</li>
</ol> </ol>
</nav> </nav>

View File

@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}{{ user.username }} - mGuard VPN Manager{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/users">Benutzer</a></li>
<li class="breadcrumb-item active">{{ user.username }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1><i class="bi bi-person"></i> {{ user.username }}</h1>
<p class="text-muted mb-0">{{ user.email }}</p>
</div>
<div>
<a href="/users" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</a>
<a href="/users/{{ user.id }}/edit" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Bearbeiten
</a>
<a href="/users/{{ user.id }}/access" class="btn btn-primary">
<i class="bi bi-key"></i> Zugriffe verwalten
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-person-badge"></i> Benutzerinformationen</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<th style="width: 40%;">Benutzername</th>
<td>{{ user.username }}</td>
</tr>
<tr>
<th>E-Mail</th>
<td>{{ user.email }}</td>
</tr>
<tr>
<th>Voller Name</th>
<td>{{ user.full_name or '-' }}</td>
</tr>
<tr>
<th>Rolle</th>
<td>
{% if user.role.value == 'super_admin' %}
<span class="badge bg-danger">Super Admin</span>
{% elif user.role.value == 'admin' %}
<span class="badge bg-primary">Admin</span>
{% elif user.role.value == 'technician' %}
<span class="badge bg-success">Techniker</span>
{% else %}
<span class="badge bg-secondary">{{ user.role.value }}</span>
{% endif %}
</td>
</tr>
<tr>
<th>Mandant</th>
<td>
{% if user.tenant %}
<a href="/tenants/{{ user.tenant.id }}">{{ user.tenant.name }}</a>
{% else %}
<span class="text-muted">- (Global)</span>
{% endif %}
</td>
</tr>
<tr>
<th>Status</th>
<td>
{% if user.is_active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
</tr>
<tr>
<th>Erstellt am</th>
<td>{{ user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '-' }}</td>
</tr>
<tr>
<th>Letzte Anmeldung</th>
<td>{{ user.last_login.strftime('%d.%m.%Y %H:%M') if user.last_login else 'Noch nie' }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-router"></i> Gateway-Zugriffe</h5>
</div>
<div class="card-body">
{% if user.role.value in ['super_admin', 'admin'] %}
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
{% if user.role.value == 'super_admin' %}
Super-Admins haben automatisch Zugriff auf alle Gateways aller Mandanten.
{% else %}
Admins haben automatisch Zugriff auf alle Gateways ihres Mandanten.
{% endif %}
</div>
{% else %}
{% if user.gateway_access %}
<ul class="list-group list-group-flush">
{% for access in user.gateway_access %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<a href="/gateways/{{ access.gateway.id }}">{{ access.gateway.name }}</a>
{% if access.gateway.location %}
<small class="text-muted ms-2">{{ access.gateway.location }}</small>
{% endif %}
</span>
{% if access.gateway.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Keine Gateway-Zugriffe zugewiesen.</p>
{% endif %}
<div class="mt-3">
<a href="/users/{{ user.id }}/access" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Zugriffe bearbeiten
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -25,8 +25,7 @@
<label class="form-label">Benutzername *</label> <label class="form-label">Benutzername *</label>
<input type="text" class="form-control" name="username" required <input type="text" class="form-control" name="username" required
value="{{ user.username if user else '' }}" value="{{ user.username if user else '' }}"
placeholder="benutzername" placeholder="benutzername">
{{ 'readonly' if user else '' }}>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -61,13 +60,15 @@
</select> </select>
</div> </div>
{% if current_user.is_super_admin and not user %} {% if current_user.is_super_admin %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Mandant</label> <label class="form-label">Mandant</label>
<select class="form-select" name="tenant_id"> <select class="form-select" name="tenant_id">
<option value="">Kein Mandant (Super Admin)</option> <option value="">Kein Mandant (Super Admin)</option>
{% for tenant in tenants %} {% for tenant in tenants %}
<option value="{{ tenant.id }}">{{ tenant.name }}</option> <option value="{{ tenant.id }}" {{ 'selected' if user and user.tenant_id == tenant.id }}>
{{ tenant.name }}
</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

View File

@ -38,7 +38,7 @@ def create_access_token(
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = { to_encode = {
"sub": user_id, "sub": str(user_id), # JWT spec requires sub to be a string
"username": username, "username": username,
"role": role, "role": role,
"tenant_id": tenant_id, "tenant_id": tenant_id,
@ -59,7 +59,7 @@ def create_refresh_token(
expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days) expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
to_encode = { to_encode = {
"sub": user_id, "sub": str(user_id), # JWT spec requires sub to be a string
"exp": expire, "exp": expire,
"type": "refresh" "type": "refresh"
} }
@ -71,5 +71,8 @@ def decode_token(token: str) -> dict | None:
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload return payload
except JWTError: except JWTError as e:
print(f"JWT decode error: {e}")
print(f"Token (first 50 chars): {token[:50]}...")
print(f"Secret key (first 10 chars): {settings.secret_key[:10]}...")
return None return None

View File

@ -104,6 +104,35 @@ async def create_user(
return RedirectResponse(url="/users", status_code=303) return RedirectResponse(url="/users", status_code=303)
@router.get("/users/{user_id}", response_class=HTMLResponse)
async def user_detail(
request: Request,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""User detail page."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
flash(request, "Benutzer nicht gefunden", "danger")
return RedirectResponse(url="/users", status_code=303)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN and user.tenant_id != current_user.tenant_id:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/users", status_code=303)
return request.app.state.templates.TemplateResponse(
"users/detail.html",
{
"request": request,
"current_user": current_user,
"user": user,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/users/{user_id}/access", response_class=HTMLResponse) @router.get("/users/{user_id}/access", response_class=HTMLResponse)
async def user_access( async def user_access(
request: Request, request: Request,
@ -126,10 +155,10 @@ async def user_access(
else: else:
gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all() gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all()
user_access = db.query(UserGatewayAccess).filter( user_access_list = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user_id UserGatewayAccess.user_id == user_id
).all() ).all()
access_gateway_ids = [a.gateway_id for a in user_access] access_gateway_ids = [a.gateway_id for a in user_access_list]
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"users/access.html", "users/access.html",
@ -142,3 +171,144 @@ async def user_access(
"flash_messages": get_flashed_messages(request) "flash_messages": get_flashed_messages(request)
} }
) )
@router.post("/users/{user_id}/access")
async def save_user_access(
request: Request,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Save user gateway access."""
from ..models.gateway import Gateway
from ..models.access import UserGatewayAccess
user = db.query(User).filter(User.id == user_id).first()
if not user:
flash(request, "Benutzer nicht gefunden", "danger")
return RedirectResponse(url="/users", status_code=303)
# Get form data
form_data = await request.form()
selected_gateway_ids = [int(gw_id) for gw_id in form_data.getlist("gateway_ids")]
# Get available gateways
if current_user.role == UserRole.SUPER_ADMIN:
available_gateways = db.query(Gateway).all()
else:
available_gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all()
available_gateway_ids = [g.id for g in available_gateways]
# Remove existing access for available gateways
db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user_id,
UserGatewayAccess.gateway_id.in_(available_gateway_ids)
).delete(synchronize_session=False)
# Add new access
for gateway_id in selected_gateway_ids:
if gateway_id in available_gateway_ids:
access = UserGatewayAccess(
user_id=user_id,
gateway_id=gateway_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
flash(request, f"Zugriffe für '{user.username}' aktualisiert", "success")
return RedirectResponse(url=f"/users/{user_id}/access", status_code=303)
@router.get("/users/{user_id}/edit", response_class=HTMLResponse)
async def edit_user_form(
request: Request,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit user form."""
from ..models.tenant import Tenant
user = db.query(User).filter(User.id == user_id).first()
if not user:
flash(request, "Benutzer nicht gefunden", "danger")
return RedirectResponse(url="/users", status_code=303)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN and user.tenant_id != current_user.tenant_id:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/users", status_code=303)
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"users/form.html",
{
"request": request,
"current_user": current_user,
"user": user,
"tenants": tenants,
"roles": [r.value for r in UserRole if r != UserRole.SUPER_ADMIN or current_user.role == UserRole.SUPER_ADMIN],
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/users/{user_id}/edit")
async def update_user(
request: Request,
user_id: int,
username: str = Form(...),
email: str = Form(...),
password: str = Form(None),
role: str = Form(...),
full_name: str = Form(None),
tenant_id: int = Form(None),
is_active: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
flash(request, "Benutzer nicht gefunden", "danger")
return RedirectResponse(url="/users", status_code=303)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN and user.tenant_id != current_user.tenant_id:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/users", status_code=303)
# Check if username is taken by another user
existing = db.query(User).filter(User.username == username, User.id != user_id).first()
if existing:
flash(request, "Benutzername bereits vergeben", "danger")
return RedirectResponse(url=f"/users/{user_id}/edit", status_code=303)
# Check if email is taken by another user
existing = db.query(User).filter(User.email == email, User.id != user_id).first()
if existing:
flash(request, "E-Mail bereits vergeben", "danger")
return RedirectResponse(url=f"/users/{user_id}/edit", status_code=303)
user.username = username
user.email = email
user.role = UserRole(role)
user.full_name = full_name or None
user.is_active = is_active is not None
# Only update password if provided
if password:
user.password_hash = get_password_hash(password)
# Update tenant
if current_user.role == UserRole.SUPER_ADMIN:
user.tenant_id = tenant_id if UserRole(role) != UserRole.SUPER_ADMIN else None
db.commit()
flash(request, f"Benutzer '{username}' aktualisiert", "success")
return RedirectResponse(url=f"/users/{user_id}", status_code=303)