From 7f81932fa51ae69abf1d66c185cc85c13d8eb8a2 Mon Sep 17 00:00:00 2001
From: Bjoern Runden
Date: Tue, 4 Aug 2026 23:07:18 +0200
Subject: [PATCH] auth ist im kasten
---
.env.example | 90 +++++++---
AGENTS.md | 1 +
config.py | 38 +++-
config.py.orig | 178 -------------------
cookies.txt | 5 +
docs/feinkonzept.md | 68 ++++++++
fix_routes_api.patch | 18 --
routes/admin.py | 107 +++++++++++-
routes/auth.py | 271 ++++++++---------------------
services/auth_service.py | 51 ++++--
services/user_admin_service.py | 51 +++++-
templates/admin/users/list.html | 109 ++++++++++++
templates/admin/users/tenants.html | 123 +++++++++++++
templates/admin/users/view.html | 176 +++++++++++++++++++
14 files changed, 843 insertions(+), 443 deletions(-)
delete mode 100644 config.py.orig
create mode 100644 cookies.txt
create mode 100644 docs/feinkonzept.md
delete mode 100644 fix_routes_api.patch
create mode 100644 templates/admin/users/list.html
create mode 100644 templates/admin/users/tenants.html
create mode 100644 templates/admin/users/view.html
diff --git a/.env.example b/.env.example
index cd143f5..b86b15a 100644
--- a/.env.example
+++ b/.env.example
@@ -22,11 +22,43 @@ FLASK_DEBUG=true
# Development Mode - enables fake auth and mocking
DEV_MODE=true
-APP_NAME=formeleon_dev
+# Mock Authentication Mode
+# When MOCK_AUTH=true AND DEV_MODE=true, uses local mock users instead of Zitadel OIDC
+# Useful for local development without external dependencies
+MOCK_AUTH=false
+
+# Application name
+APP_NAME=formeleon
# Application port (default: 5000)
-# PORT=5000
+PORT=5000
+# =============================================================================
+# MOCK USER SETTINGS (used when MOCK_AUTH=true and DEV_MODE=true)
+# =============================================================================
+
+# Mock user credentials (only used in mock auth mode)
+MOCK_USER_ID=mock_user_001
+MOCK_USER_ROLE=admin
+MOCK_USER_NAME=Mock Admin
+MOCK_USER_EMAIL=mock@formeleon.local
+MOCK_TENANT_ID=mock_tenant_001
+
+# =============================================================================
+# DEVELOPMENT USER SETTINGS (used when DEV_MODE=true but MOCK_AUTH=false)
+# =============================================================================
+
+# Dev Mode User Role (admin, tenant_admin, user, viewer)
+DEV_USER_ROLE=admin
+
+# Dev Mode Tenant ID
+DEV_TENANT_ID=tenant_dev_123
+
+# Dev Mode User Name
+DEV_USER_NAME="Dev Admin"
+
+# Dev Mode User Email
+DEV_USER_EMAIL=dev@formeleon.local
# =============================================================================
# DATABASE SETTINGS
# =============================================================================
@@ -80,22 +112,6 @@ RECAPTCHA_THRESHOLD=0.5
# Mock score for development mode (default: 0.9)
MOCK_RECAPTCHA_SCORE=0.9
-# =============================================================================
-# DEVELOPMENT USER SETTINGS
-# =============================================================================
-
-# Dev Mode User Role (admin, tenant_admin, user, viewer)
-DEV_USER_ROLE=admin
-
-# Dev Mode Tenant ID
-DEV_TENANT_ID=tenant_dev_123
-
-# Dev Mode User Name
-DEV_USER_NAME=Dev Admin
-
-# Dev Mode User Email
-DEV_USER_EMAIL=dev@formeleon.local
-
# =============================================================================
# CORS SETTINGS
# =============================================================================
@@ -116,6 +132,9 @@ SESSION_COOKIE_SECURE=false
# Session Cookie SameSite (Lax, Strict, None)
SESSION_COOKIE_SAMESITE=Lax
+# Session lifetime in seconds (default: 3600 = 1 hour)
+PERMANENT_SESSION_LIFETIME=3600
+
# =============================================================================
# FILE UPLOAD SETTINGS
# =============================================================================
@@ -152,10 +171,31 @@ UPLOAD_FOLDER=/tmp/formeleon_uploads
# PRODUCTION SETTINGS
# =============================================================================
-# In production, set these values:
-# FLASK_ENV=production
-# FLASK_DEBUG=false
-# DEV_MODE=false
-# SESSION_COOKIE_SECURE=true
-# SESSION_COOKIE_SAMESITE=Strict
-# SQLALCHEMY_DATABASE_URI=your-production-database-uri
+# Default page size for paginated responses
+DEFAULT_PAGE_SIZE=20
+
+# Maximum page size for paginated responses
+MAX_PAGE_SIZE=100
+
+# =============================================================================
+# NOTES FOR PRODUCTION
+# =============================================================================
+
+# For production deployment:
+# 1. Set FLASK_ENV=production
+# 2. Set FLASK_DEBUG=false
+# 3. Set DEV_MODE=false
+# 4. Set MOCK_AUTH=false
+# 5. Set SESSION_COOKIE_SECURE=true
+# 6. Set SESSION_COOKIE_SAMESITE=Strict
+# 7. Configure proper ZITADEL_* settings
+# 8. Configure proper RECAPTCHA_* settings
+# 9. Use a production-ready database (PostgreSQL, MySQL)
+# 10. Generate a strong SECRET_KEY
+# 11. Restrict ALLOWED_ORIGINS to your specific domains
+# 12. Set up proper HTTPS/TLS
+
+# For local development without external services:
+# 1. Set DEV_MODE=true
+# 2. Set MOCK_AUTH=true (optional, for pure local mocking)
+# 3. Optionally configure ZITADEL_* settings if you have a local Zitadel instance
diff --git a/AGENTS.md b/AGENTS.md
index b733ad1..f2b90cc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,6 +27,7 @@ Jede neu erstellte oder modifizierte Quelltext-Datei (`.py`, `.j2`, `.html`, `.j
Bitte lies vor der Bearbeitung spezifischer Module die folgenden Dokumente im Repository:
+* **Feinkonzept & Spezifikation:** Read [`docs/feinkonzept.md`](./docs/feinkonzept.md)
* **Header & PyDoc Standards:** Read [`docs/coding-standards.md`](./docs/coding-standards.md)
* **System-Architektur & DB:** Read [`ARCHITEKTUR.md`](./ARCHITEKTUR.md)
* **CI/CD & Deployment:** Read [`docs/deployment.md`](./docs/deployment.md)
diff --git a/config.py b/config.py
index de62b85..0a138b6 100644
--- a/config.py
+++ b/config.py
@@ -18,6 +18,14 @@ Environment Variables:
- SQLALCHEMY_DATABASE_URI: Database connection string
- SQLALCHEMY_TRACK_MODIFICATIONS: Track modifications (default: False)
+ - MOCK_AUTH: Enable mock authentication mode (default: False)
+ Requires DEV_MODE=true. When enabled, uses local mock users instead of Zitadel OIDC.
+ - MOCK_USER_ID: User ID for mock auth (default: 'mock_user_001')
+ - MOCK_USER_ROLE: Role for mock user (default: 'admin')
+ - MOCK_USER_NAME: Name for mock user (default: 'Mock Admin')
+ - MOCK_USER_EMAIL: Email for mock user (default: 'mock@formeleon.local')
+ - MOCK_TENANT_ID: Tenant ID for mock auth (default: 'mock_tenant_001')
+
- ZITADEL_ISSUER: OIDC issuer URL for Zitadel
- ZITADEL_CLIENT_ID: OIDC client ID
- ZITADEL_CLIENT_SECRET: OIDC client secret
@@ -55,12 +63,23 @@ class Config:
# Development Mode
DEV_MODE: bool = os.environ.get('DEV_MODE', 'false').lower() == 'true'
+ # Mock Authentication Mode
+ # When MOCK_AUTH=true and DEV_MODE=true, use local mock users instead of Zitadel OIDC
+ MOCK_AUTH: bool = os.environ.get('MOCK_AUTH', 'false').lower() == 'true'
+
# Dev User Settings
DEV_USER_ROLE: str = os.environ.get('DEV_USER_ROLE', 'admin')
DEV_TENANT_ID: str = os.environ.get('DEV_TENANT_ID', 'tenant_dev_123')
DEV_USER_NAME: str = os.environ.get('DEV_USER_NAME', 'Dev Admin')
DEV_USER_EMAIL: str = os.environ.get('DEV_USER_EMAIL', 'dev@formeleon.local')
+ # Mock User Settings (used when MOCK_AUTH is enabled)
+ MOCK_USER_ID: str = os.environ.get('MOCK_USER_ID', 'mock_user_001')
+ MOCK_USER_ROLE: str = os.environ.get('MOCK_USER_ROLE', 'admin')
+ MOCK_USER_NAME: str = os.environ.get('MOCK_USER_NAME', 'Mock Admin')
+ MOCK_USER_EMAIL: str = os.environ.get('MOCK_USER_EMAIL', 'mock@formeleon.local')
+ MOCK_TENANT_ID: str = os.environ.get('MOCK_TENANT_ID', 'mock_tenant_001')
+
# Database Configuration
if 'SQLALCHEMY_DATABASE_URI' in os.environ:
SQLALCHEMY_DATABASE_URI: str = os.environ['SQLALCHEMY_DATABASE_URI']
@@ -130,8 +149,15 @@ class Config:
:return: True if all required Zitadel settings are present, False otherwise.
"""
return (self.ZITADEL_ISSUER is not None and
- self.ZITADEL_CLIENT_ID is not None and
- self.ZITADEL_CLIENT_SECRET is not None)
+ self.ZITADEL_CLIENT_ID is not None )
+
+ def is_mock_auth_enabled(self) -> bool:
+ """
+ Check if mock authentication mode is enabled.
+
+ :return: True if both DEV_MODE and MOCK_AUTH are enabled, False otherwise.
+ """
+ return self.DEV_MODE and self.MOCK_AUTH
class DevelopmentConfig(Config):
@@ -140,10 +166,10 @@ class DevelopmentConfig(Config):
Overrides base configuration with development-friendly settings.
"""
- DEBUG: bool = True
- DEV_MODE: bool = True
- SQLALCHEMY_ECHO: bool = True
- SESSION_COOKIE_SECURE: bool = False
+# DEBUG: bool = True
+# DEV_MODE: bool = True
+# SQLALCHEMY_ECHO: bool = True
+# SESSION_COOKIE_SECURE: bool = False
class ProductionConfig(Config):
diff --git a/config.py.orig b/config.py.orig
deleted file mode 100644
index f52d49c..0000000
--- a/config.py.orig
+++ /dev/null
@@ -1,178 +0,0 @@
-# ==============================================================================
-# Formeleon - Multi-tenant Form Generator
-# Author: Björn Runden
-# Module: config.py
-# Description: Application configuration with environment variable support.
-# Handles development mode, reCAPTCHA, Zitadel OIDC, and database settings.
-# ==============================================================================
-
-"""
-Application configuration module for Formeleon.
-
-This module centralizes all configuration settings for the Flask application,
-loading from environment variables with sensible defaults for development.
-
-Environment Variables:
- - DEV_MODE: Enable development mode (default: False)
- - SECRET_KEY: Flask secret key for session management
- - SQLALCHEMY_DATABASE_URI: Database connection string
- - SQLALCHEMY_TRACK_MODIFICATIONS: Track modifications (default: False)
-
- - ZITADEL_ISSUER: OIDC issuer URL for Zitadel
- - ZITADEL_CLIENT_ID: OIDC client ID
- - ZITADEL_CLIENT_SECRET: OIDC client secret
- - ZITADEL_REDIRECT_URI: OIDC redirect URI
-
- - RECAPTCHA_SECRET_KEY: reCAPTCHA v3 secret key
- - RECAPTCHA_SITE_KEY: reCAPTCHA v3 site key
- - RECAPTCHA_THRESHOLD: Minimum score threshold (default: 0.5)
- - MOCK_RECAPTCHA_SCORE: Mock score for dev mode (default: 0.9)
-
- - DEV_USER_ROLE: Role for dev mode user (default: 'admin')
- - DEV_TENANT_ID: Tenant ID for dev mode (default: 'tenant_dev_123')
- - DEV_USER_NAME: Username for dev mode (default: 'Dev Admin')
- - DEV_USER_EMAIL: Email for dev mode (default: 'dev@formeleon.local')
-"""
-
-import os
-from typing import Optional
-
-
-BASE_DIR = os.path.abspath(os.path.dirname(__file__))
-
-class Config:
- """
- Base configuration class with default values.
-
- All configuration values can be overridden via environment variables.
- """
-
- # Flask Core Settings
- SECRET_KEY: str = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
- DEBUG: bool = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
- TESTING: bool = os.environ.get('FLASK_TESTING', 'false').lower() == 'true'
-
- # Development Mode
- DEV_MODE: bool = os.environ.get('DEV_MODE', 'false').lower() == 'true'
-
- # Dev User Settings
- DEV_USER_ROLE: str = os.environ.get('DEV_USER_ROLE', 'admin')
- DEV_TENANT_ID: str = os.environ.get('DEV_TENANT_ID', 'tenant_dev_123')
- DEV_USER_NAME: str = os.environ.get('DEV_USER_NAME', 'Dev Admin')
- DEV_USER_EMAIL: str = os.environ.get('DEV_USER_EMAIL', 'dev@formeleon.local')
-
- # Database Configuration
- SQLALCHEMY_DATABASE_URI: str = os.environ.get(
- 'SQLALCHEMY_DATABASE_URI',
- f'sqlite:///{os.path.join(BASE_DIR, "instance", "formeleon.db")}'
- )
- SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
- SQLALCHEMY_ECHO: bool = os.environ.get('SQLALCHEMY_ECHO', 'false').lower() == 'true'
-
- # Zitadel OIDC Configuration
- ZITADEL_ISSUER: Optional[str] = os.environ.get('ZITADEL_ISSUER')
- ZITADEL_CLIENT_ID: Optional[str] = os.environ.get('ZITADEL_CLIENT_ID')
- ZITADEL_CLIENT_SECRET: Optional[str] = os.environ.get('ZITADEL_CLIENT_SECRET')
- ZITADEL_REDIRECT_URI: Optional[str] = os.environ.get(
- 'ZITADEL_REDIRECT_URI',
- 'http://localhost:5000/auth/callback'
- )
- ZITADEL_SCOPES: list = ['openid', 'profile', 'email']
-
- # reCAPTCHA v3 Configuration
- RECAPTCHA_SECRET_KEY: Optional[str] = os.environ.get('RECAPTCHA_SECRET_KEY')
- RECAPTCHA_SITE_KEY: Optional[str] = os.environ.get('RECAPTCHA_SITE_KEY')
- RECAPTCHA_THRESHOLD: float = float(os.environ.get('RECAPTCHA_THRESHOLD', '0.5'))
- MOCK_RECAPTCHA_SCORE: float = float(os.environ.get('MOCK_RECAPTCHA_SCORE', '0.9'))
- RECAPTCHA_VERIFY_URL: str = 'https://www.google.com/recaptcha/api/siteverify'
-
- # Application Settings
- DEFAULT_PAGE_SIZE: int = 20
- MAX_PAGE_SIZE: int = 100
-
- # Form Submission Settings
- ALLOWED_ORIGINS: list = os.environ.get('ALLOWED_ORIGINS', '*').split(',')
- ENABLE_CORS: bool = os.environ.get('ENABLE_CORS', 'true').lower() == 'true'
-
- # File Upload Settings
- MAX_CONTENT_LENGTH: int = 16 * 1024 * 1024 # 16MB
- UPLOAD_FOLDER: str = os.environ.get('UPLOAD_FOLDER', '/tmp/formeleon_uploads')
- ALLOWED_EXTENSIONS: set = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt'}
-
- # Session Configuration
- SESSION_COOKIE_SECURE: bool = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
- SESSION_COOKIE_HTTPONLY: bool = True
- SESSION_COOKIE_SAMESITE: str = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
- PERMANENT_SESSION_LIFETIME: int = 3600 # 1 hour in seconds
-
- def is_dev_mode(self) -> bool:
- """
- Check if application is running in development mode.
-
- :return: True if DEV_MODE is enabled, False otherwise.
- """
- return self.DEV_MODE
-
- def is_recaptcha_enabled(self) -> bool:
- """
- Check if reCAPTCHA is properly configured.
-
- :return: True if both secret and site keys are configured, False otherwise.
- """
- return (self.RECAPTCHA_SECRET_KEY is not None and
- self.RECAPTCHA_SITE_KEY is not None)
-
- def is_zitadel_enabled(self) -> bool:
- """
- Check if Zitadel OIDC is properly configured.
-
- :return: True if all required Zitadel settings are present, False otherwise.
- """
- return (self.ZITADEL_ISSUER is not None and
- self.ZITADEL_CLIENT_ID is not None and
- self.ZITADEL_CLIENT_SECRET is not None)
-
-
-class DevelopmentConfig(Config):
- """
- Development-specific configuration.
-
- Overrides base configuration with development-friendly settings.
- """
- DEBUG: bool = True
- DEV_MODE: bool = True
- SQLALCHEMY_ECHO: bool = True
- SESSION_COOKIE_SECURE: bool = False
-
-
-class ProductionConfig(Config):
- """
- Production-specific configuration.
-
- Overrides base configuration with production-safe settings.
- """
- DEBUG: bool = False
- DEV_MODE: bool = False
- SQLALCHEMY_ECHO: bool = False
- SESSION_COOKIE_SECURE: bool = True
- SESSION_COOKIE_SAMESITE: str = 'Strict'
-
-
-class TestingConfig(Config):
- """
- Testing-specific configuration.
-
- Overrides base configuration with test-friendly settings.
- """
- TESTING: bool = True
- SQLALCHEMY_DATABASE_URI: str = 'sqlite:///:memory:'
- WTF_CSRF_ENABLED: bool = False
-
-
-# Configuration mapping for easy access
-config_map: dict = {
- 'development': DevelopmentConfig,
- 'production': ProductionConfig,
- 'testing': TestingConfig,
- 'default': DevelopmentConfig if os.environ.get('FLASK_ENV', 'development') == 'development' else ProductionConfig
-}
diff --git a/cookies.txt b/cookies.txt
new file mode 100644
index 0000000..8366d30
--- /dev/null
+++ b/cookies.txt
@@ -0,0 +1,5 @@
+# Netscape HTTP Cookie File
+# https://curl.se/docs/http-cookies.html
+# This file was generated by libcurl! Edit at your own risk.
+
+#HttpOnly_127.0.0.1 FALSE / FALSE 1785847477 session jE1XElUmcswK63EciLDkqYqHXtKQnMMBzkt6TN40b7c
diff --git a/docs/feinkonzept.md b/docs/feinkonzept.md
new file mode 100644
index 0000000..6a7d326
--- /dev/null
+++ b/docs/feinkonzept.md
@@ -0,0 +1,68 @@
+# Formeleon - Feinkonzept & Spezifikation
+
+* **Autor:** Björn Runden
+* **Status:** Freigegeben / In Umsetzung
+* **Version:** 1.0
+* **Datum:** August 2026
+
+---
+
+## 1. Übersicht & Systemkontext
+
+Formeleon ist ein mandantenfähiges Formular-Service-System (Multi-tenant Form Generator & Backend API). Das System nimmt Formular-Submissions von externen Mandanten-Webseiten entgegen, schützt diese vor Bot-Spam, speichert die Daten isoliert nach Mandanten ab und stellt ein Admin-Dashboard zur Verwaltung bereit.
+
+---
+
+## 2. Authentifizierung & Identity Management (Zitadel OIDC)
+
+* **Identity Provider:** Zitadel via OIDC (OAuth2) unter Nutzung von `authlib.integrations.flask_client.OAuth`.
+* **Scope & Mapping:** Zitadel wird ausschließlich für die Authentifizierung und Übermittlung von User-Attributen (z. B. E-Mail, Name, Sub-ID) genutzt. Es findet kein Rollen-Mapping aus Token-Claims statt.
+* **Lokale Rechteverwaltung (RBAC):** Die Zuweisung von Rollen (`admin`, `tenant_admin`, `user`, `viewer`) erfolgt strikt innerhalb der lokalen PostgreSQL/SQLite-Datenbank über die E-Mail-Adresse des Benutzers.
+* **Development Bypass:** Bei aktivierter Option `DEV_MODE=true` umgeht eine Middleware den OIDC-Redirect und injiziert eine Mock-Session für die lokale Entwicklung.
+
+---
+
+## 3. Bot-Schutz & Spam-Handling (Google reCAPTCHA v3)
+
+| Parameter | Spezifikation |
+| :--- | :--- |
+| **Technologie** | Google reCAPTCHA v3 (unsichtbare Score-basierte Validierung) |
+| **Schwellenwert (Threshold)** | Standard: `0.5` (konfigurierbar via `RECAPTCHA_MIN_SCORE` in `.env`) |
+| **Fehlerfall (Bot Risk)** | Rückgabe von HTTP Status `400 Bad Request` an den Client mit lesbarer Fehlermeldung. |
+| **Datenhaltung bei Spam** | Submissions mit Score < 0.5 werden als `flagged_as_spam` in der Datenbank gespeichert (nicht direkt gelöscht). |
+| **Benachrichtigungen** | Automatischer E-Mail-Hinweis an Admin/Mandant bei erkannter Spam-Submission (sofern Mandant auflösbar). |
+| **Dev Mode Simulation** | Mock-Service gibt standardmäßig Score `0.9` zurück (einstellbar via `MOCK_RECAPTCHA_SCORE`). |
+
+---
+
+## 4. Frontend Integration (Form-Embed Snippet)
+
+### 4.1 Embed Workflow
+
+1. **Einbindung beim Mandanten:**
+ ```html
+
+
+ ```
+2. **Handshake & Validation:** Das Snippet führt beim Laden eine Asynchron-Anforderung via Fetch API durch (`GET /api/v1/forms/`). Das Backend prüft den `Origin`-Header gegen die `allowed_origins` des Mandanten.
+3. **Rendering (Lightweight DOM Insertion):** Direktes HTML-Injecting (via `innerHTML`/DOM-Parser) in das Ziel-DOM. Bewusst kein Shadow-DOM, um das Customizing via CSS des Mandanten zu erleichtern.
+4. **Submission Handling:** Das Snippet fängt das `onsubmit`-Event ab, fordert im Hintergrund ein reCAPTCHA v3 Token an und sendet die Formulardaten per POST/JSON an das Backend.
+
+---
+
+## 5. Datenmodell & Multi-Tenancy
+
+* **Tenant Isolation:** Logische Mandantentrennung auf Datenbankebene über `tenant_id` an den Entitäten `FormConfig`, `Submission` und `User`.
+* **Public Keys & Hashes:** Öffentliche Schlüssel für das Embedding haben das Format `pk_live_` und enthalten intern einen SHA-basierten Verweis auf die `tenant_id`.
+* **Flexible Payloads:** Die Submissions speichern dynamische Eingabefelder in einem JSON-Payload-Feld der Datenbank ab, um feingranulare Schema-Änderungen ohne Schema-Migrationen zu ermöglichen.
+
+---
+
+## 6. Anweisungen für KI-Coding-Agenten
+
+Bei der Generierung und Bearbeitung von Code in diesem Repository sind folgende Punkte strikt zu beachten:
+
+1. **Modularität:** Jede neue Route muss im entsprechenden Blueprint (`routes/auth.py`, `routes/api.py`, `routes/admin.py`) registriert werden.
+2. **ORM-Pflicht:** Datenbankzugriffe ausschließlich über Flask-SQLAlchemy durchführen. Schema-Änderungen über Flask-Migrate anlegen.
+3. **Header & PyDoc:** Alle neu erstellten Dateien müssen den Kopf gemäß `docs/coding-standards.md` tragen. PyDoc-Docstrings sind für Funktionen Pflicht.
+4. **Git-Branching:** Entwicklung erfolgt ausschließlich auf dem Branch `main`. Merges auf `stable` sind der CI/CD-Pipeline vorbehalten.
\ No newline at end of file
diff --git a/fix_routes_api.patch b/fix_routes_api.patch
deleted file mode 100644
index 8e77a35..0000000
--- a/fix_routes_api.patch
+++ /dev/null
@@ -1,18 +0,0 @@
---- routes/api.py
-+++ routes/api.py
-@@ -58,15 +58,6 @@
- def before_api_request():
- """Before request handler for API routes."""
-- # Set CORS headers for API routes
-- if current_app.config.get('ENABLE_CORS', True):
-- response = current_app.make_default_options_response()
-- response.headers['Access-Control-Allow-Origin'] = '*'
-- response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
-- response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Public-Key, X-Request-ID'
--
- # Handle OPTIONS requests
- if request.method == 'OPTIONS':
-- return response
-+ return current_app.make_default_options_response()
-
- # Skip authentication for status endpoint and options requests
diff --git a/routes/admin.py b/routes/admin.py
index 5f9e393..8dc341b 100644
--- a/routes/admin.py
+++ b/routes/admin.py
@@ -524,20 +524,21 @@ def update_submission_status(submission_id):
@admin_required
def list_users():
"""List all users (admin only)."""
- user = get_current_user()
+ admin_user = get_current_user()
users = UserAdminService.get_users()
return render_template('admin/users/list.html',
- user=user,
+ admin_user=admin_user,
users=users,
- dev_mode=current_app.config.get('DEV_MODE', False))
+ dev_mode=current_app.config.get('DEV_MODE', False),
+ is_admin=admin_user.is_admin())
@admin_bp.route('/users/', methods=['GET', 'POST'])
@admin_required
def view_user(user_id):
"""View and edit a user (admin only)."""
- user = get_current_user()
+ admin_user = get_current_user()
target_user = User.query.get(user_id)
if not target_user:
@@ -546,7 +547,7 @@ def view_user(user_id):
if request.method == 'POST':
UserAdminService.update_user(
target_user, request.form.get('email'), request.form.get('name'),
- request.form.get('role'), user
+ request.form.get('role'), admin_user
)
flash('User updated successfully!', 'success')
@@ -556,7 +557,7 @@ def view_user(user_id):
tenants = target_user.tenants
return render_template('admin/users/view.html',
- user=user,
+ admin_user=admin_user,
target_user=target_user,
tenants=tenants,
dev_mode=current_app.config.get('DEV_MODE', False),
@@ -584,6 +585,100 @@ def delete_user(user_id):
return redirect(url_for('admin.list_users'))
+@admin_bp.route('/users//tenants', methods=['GET', 'POST'])
+@admin_required
+def manage_user_tenants(user_id):
+ """Manage tenant assignments for a user (admin only)."""
+ admin_user = get_current_user()
+ target_user = User.query.get(user_id)
+
+ if not target_user:
+ abort(404, description='User not found')
+
+ if request.method == 'POST':
+ # Add or remove tenant assignments
+ action = request.form.get('action')
+ tenant_id = request.form.get('tenant_id')
+
+ if action == 'add' and tenant_id:
+ tenant = Tenant.query.get(tenant_id)
+ if tenant and tenant not in target_user.tenants:
+ target_user.tenants.append(tenant)
+ db.session.commit()
+
+ AuditLog.log_action(
+ action='assign_tenant',
+ resource_type='user',
+ user=admin_user,
+ resource_id=target_user.id,
+ details={'tenant_id': tenant_id, 'tenant_name': tenant.name}
+ )
+ flash(f'User assigned to tenant {tenant.name}!', 'success')
+
+ elif action == 'remove' and tenant_id:
+ tenant = Tenant.query.get(tenant_id)
+ if tenant and tenant in target_user.tenants:
+ target_user.tenants.remove(tenant)
+ db.session.commit()
+
+ AuditLog.log_action(
+ action='remove_tenant',
+ resource_type='user',
+ user=admin_user,
+ resource_id=target_user.id,
+ details={'tenant_id': tenant_id, 'tenant_name': tenant.name}
+ )
+ flash(f'User removed from tenant {tenant.name}!', 'success')
+
+ return redirect(url_for('admin.manage_user_tenants', user_id=target_user.id))
+
+ # Get all tenants and user's current tenants
+ all_tenants = Tenant.query.order_by(Tenant.name).all()
+ user_tenants = list(target_user.tenants)
+
+ return render_template('admin/users/tenants.html',
+ admin_user=admin_user,
+ target_user=target_user,
+ all_tenants=all_tenants,
+ user_tenants=user_tenants,
+ dev_mode=current_app.config.get('DEV_MODE', False))
+
+
+@admin_bp.route('/users//role', methods=['POST'])
+@admin_required
+def update_user_role(user_id):
+ """Update user role (admin only)."""
+ admin_user = get_current_user()
+ target_user = User.query.get(user_id)
+
+ if not target_user:
+ abort(404, description='User not found')
+
+ # Don't allow changing own role
+ if target_user.id == admin_user.id:
+ flash('Cannot change your own role!', 'error')
+ return redirect(url_for('admin.view_user', user_id=target_user.id))
+
+ new_role = request.form.get('role')
+ if new_role and new_role in [r.value for r in UserRole]:
+ old_role = target_user.role.value
+ target_user.role = UserRole(new_role)
+ db.session.commit()
+
+ AuditLog.log_action(
+ action='update_role',
+ resource_type='user',
+ user=admin_user,
+ resource_id=target_user.id,
+ details={'old_role': old_role, 'new_role': new_role}
+ )
+ flash(f'User role updated to {new_role}!', 'success')
+ else:
+ flash('Invalid role!', 'error')
+
+ return redirect(url_for('admin.view_user', user_id=target_user.id))
+
+
@admin_bp.route('/profile')
@login_required
def profile():
diff --git a/routes/auth.py b/routes/auth.py
index 0274db1..1f1e448 100644
--- a/routes/auth.py
+++ b/routes/auth.py
@@ -8,99 +8,96 @@
"""
Authentication routes for Formeleon.
-
-This module provides:
-- OIDC authentication via Zitadel (production)
-- Development mode bypass with fake user injection
-- Login/logout routes
-- Session management
-
-Routes:
- GET /auth/login - Initiate OIDC login flow or redirect to admin
- GET /auth/callback - OIDC callback handler
- GET /auth/logout - Logout and clear session
- GET /auth/status - Check current authentication status
-
-In development mode (DEV_MODE=true), the login route automatically
-creates a fake authenticated session without redirecting to Zitadel.
"""
-import uuid
+import os
import logging
from typing import Optional
-from urllib.parse import urlencode
from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify
-from flask import render_template_string
+from authlib.integrations.flask_client import OAuth
from models import db, User, UserRole, Tenant, AuditLog
from services.auth_service import AuthService
-
logger = logging.getLogger(__name__)
+# Modul-globales OAuth Objekt
+oauth = OAuth()
+
# Create auth blueprint
auth_bp = Blueprint('auth', __name__, template_folder='../templates')
+os.environ['AUTHLIB_INSECURE_TRANSPORT'] = '1'
+
+
+def _init_zitadel_client():
+ """Hilfsfunktion: Registriert den Zitadel-Client sicher bei Authlib."""
+ # HTTP im Dev/Mock-Modus für Authlib erlauben (nur lokal!)
+ if AuthService.is_dev_mode() or AuthService.is_mock_auth_enabled():
+ os.environ['AUTHLIB_INSECURE_TRANSPORT'] = '1'
+
+ if 'zitadel' not in oauth._clients:
+ issuer = current_app.config.get('ZITADEL_ISSUER', '').rstrip('/')
+ client_secret = current_app.config.get('ZITADEL_CLIENT_SECRET')
+
+ scopes = current_app.config.get('ZITADEL_SCOPES', ['openid', 'profile', 'email'])
+ scope_string = ' '.join(scopes) if isinstance(scopes, list) else scopes
+
+ zitadel_config = {
+ 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
+ 'client_secret': client_secret,
+ 'server_metadata_url': f"{issuer}/.well-known/openid-configuration",
+ 'client_kwargs': {
+ 'scope': scope_string,
+ }
+ }
+
+ # Pure PKCE (Public App) ohne Client Secret
+ if not client_secret:
+ zitadel_config['client_kwargs']['token_endpoint_auth_method'] = 'none'
+
+ oauth.init_app(current_app)
+ oauth.register('zitadel', **zitadel_config)
+
+
@auth_bp.before_request
def check_dev_mode_auth():
- """
- Middleware to handle development mode and mock authentication.
-
- If DEV_MODE is enabled and user is not authenticated:
- - If MOCK_AUTH is enabled: create mock user session
- - Otherwise: create dev user session
- """
- # Skip for auth routes themselves
+ """Middleware to handle development mode and mock authentication."""
if request.path in ['/auth/login', '/auth/callback', '/auth/logout', '/auth/status']:
return
- # Skip if already authenticated
if 'user_id' in session and session.get('authenticated'):
return
- # In dev mode, auto-authenticate
if AuthService.is_dev_mode():
if AuthService.is_mock_auth_enabled():
- # Use mock authentication
mock_user = AuthService.create_mock_user_session()
session['user_id'] = mock_user['id']
session['user'] = mock_user
session['authenticated'] = True
session.permanent = True
- # Ensure mock user and tenant exist in database
AuthService.ensure_mock_user_exists()
AuthService.ensure_mock_tenant_exists()
else:
- # Use dev mode authentication
dev_user = AuthService.create_dev_user_session()
session['user_id'] = dev_user['id']
session['user'] = dev_user
session['authenticated'] = True
session.permanent = True
- # Ensure user and tenant exist in database
AuthService.ensure_dev_user_exists()
AuthService.ensure_dev_tenant_exists()
@auth_bp.route('/login')
def login():
- """
- Login route - initiates OIDC flow or handles dev/mock mode.
-
- In production: Redirects to Zitadel for OIDC authentication
- In development with MOCK_AUTH: Creates mock session and redirects to admin
- In development without MOCK_AUTH: Creates fake session and redirects to admin
-
- Query parameters:
- next: URL to redirect to after successful login
- """
+ """Initiates OIDC flow via Authlib or handles dev/mock mode."""
next_url = request.args.get('next', url_for('admin.dashboard'))
- # Mock authentication mode: create mock session and redirect
+ # Mock authentication mode
if AuthService.is_mock_auth_enabled():
mock_user = AuthService.create_mock_user_session()
session.clear()
@@ -109,12 +106,9 @@ def login():
session['authenticated'] = True
session.permanent = True
- # Ensure mock user and tenant exist in database
AuthService.ensure_mock_user_exists()
AuthService.ensure_mock_tenant_exists()
- logger.info(f'Mock auth login: {mock_user["email"]}')
-
AuditLog.log_action(
action='login',
resource_type='auth',
@@ -122,10 +116,9 @@ def login():
tenant=Tenant.query.get(mock_user['tenant_id']),
details={'mode': 'mock', 'mock_auth': True, 'dev_mode': True}
)
-
return redirect(next_url)
- # Development mode (without MOCK_AUTH): create fake session and redirect
+ # Development mode (without MOCK_AUTH)
if AuthService.is_dev_mode():
dev_user = AuthService.create_dev_user_session()
session.clear()
@@ -134,12 +127,9 @@ def login():
session['authenticated'] = True
session.permanent = True
- # Ensure user and tenant exist in database
AuthService.ensure_dev_user_exists()
AuthService.ensure_dev_tenant_exists()
- logger.info(f'Dev mode login: {dev_user["email"]}')
-
AuditLog.log_action(
action='login',
resource_type='auth',
@@ -147,135 +137,50 @@ def login():
tenant=Tenant.query.get(dev_user['tenant_id']),
details={'mode': 'dev', 'dev_mode': True}
)
-
return redirect(next_url)
- # Production mode: redirect to Zitadel OIDC
- if not current_app.config['ZITADEL_ISSUER']:
+ # Production OIDC mode
+ if not current_app.config.get('ZITADEL_ISSUER'):
return jsonify({'error': 'OIDC not configured'}), 500
- # Build authorization URL
- from authlib.integrations.flask_client import OAuth
- from flask import current_app
-
- oauth = OAuth(current_app)
-
- # Configure Zitadel if not already configured
- if 'zitadel' not in oauth._clients:
- issuer = current_app.config.get('ZITADEL_ISSUER', '').rstrip('/')
- client_secret = current_app.config.get('ZITADEL_CLIENT_SECRET')
-
- # Scopes aus Config lesen oder auf OIDC-Standard-Defaults zurückfallen
- scopes = current_app.config.get('ZITADEL_SCOPES')
- if not scopes:
- scopes = ['openid', 'profile', 'email']
- scope_string = ' '.join(scopes) if isinstance(scopes, list) else scopes
+ _init_zitadel_client()
- zitadel_config = {
- 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
- 'client_secret': client_secret,
- # Automatische Konfiguration über Zitadels Discovery Endpoint
- 'server_metadata_url': f"{issuer}/.well-known/openid-configuration",
- 'client_kwargs': {
- 'scope': scope_string,
- 'code_challenge_method': 'S256', # PKCE Aktivierung
- # Falls KEIN Client Secret vorhanden ist (Pure PKCE Public App):
- # 'token_endpoint_auth_method': 'none' if not client_secret else 'client_secret_post'
- }
- }
- # Nur hinzufügen, wenn kein Secret da ist
- if not client_secret:
- zitadel_config['client_kwargs']['token_endpoint_auth_method'] = 'none'
-
- oauth.register('zitadel', **zitadel_config)
-
- # Generate state for CSRF protection
- state = str(uuid.uuid4())
- session['oauth_state'] = state
+ # Nach dem Callback gewünschte Ziel-URL speichern
session['oauth_next'] = next_url
- # Build authorization URL
- redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
- url_for('auth.callback', _external=True))
+ redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI') or url_for('auth.callback', _external=True)
- authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v2/oauth/authorize"
-
- params = {
- 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
- 'redirect_uri': redirect_uri,
- 'response_type': 'code',
- 'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])),
- 'state': state,
- 'nonce': str(uuid.uuid4())
- }
-
- auth_url = f"{authorization_url}?{urlencode(params)}"
-
- logger.debug(f'Redirecting to OIDC provider: {auth_url}')
- return redirect(auth_url)
+ # Authlib kümmert sich hier vollautomatisch um CSRF-State, Nonce und PKCE Code Challenge!
+ return oauth.zitadel.authorize_redirect(redirect_uri)
@auth_bp.route('/callback')
def callback():
- """
- OIDC callback handler.
-
- Handles the callback from Zitadel after successful authentication.
- Exchanges authorization code for tokens and retrieves user info.
- In mock auth mode, this route shouldn't be reached, but redirects gracefully.
- """
- if AuthService.is_mock_auth_enabled():
- # In mock auth mode, shouldn't reach here, but handle gracefully
- logger.debug('Mock auth mode: callback route accessed, redirecting to dashboard')
+ """OIDC callback handler."""
+ if AuthService.is_mock_auth_enabled() or AuthService.is_dev_mode():
return redirect(url_for('admin.dashboard'))
- if AuthService.is_dev_mode():
- # In dev mode, shouldn't reach here, but handle gracefully
- return redirect(url_for('admin.dashboard'))
-
- # Verify state for CSRF protection
- state = request.args.get('state')
- stored_state = session.pop('oauth_state', None)
-
- if not state or state != stored_state:
- logger.error('OIDC callback: Invalid state parameter')
- return jsonify({'error': 'Invalid state parameter'}), 403
-
- # Get authorization code
- code = request.args.get('code')
- if not code:
- error = request.args.get('error', 'No authorization code received')
- logger.error(f'OIDC callback error: {error}')
- return jsonify({'error': error}), 400
-
- # Exchange code for tokens
+ if 'error' in request.args:
+ error_msg = request.args.get('error_description', request.args.get('error'))
+ logger.error(f'OIDC provider returned error: {error_msg}')
+ return jsonify({'error': error_msg}), 400
+
try:
- from authlib.integrations.flask_client import OAuth
- oauth = OAuth(current_app)
-
- redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
- url_for('auth.callback', _external=True))
-
- zitadel = oauth.create_client('zitadel')
-
- # Get access token
- token = zitadel.authorize_access_token(
- redirect_uri=redirect_uri,
- code=code
- )
-
- # Get user info
- user_info = zitadel.userinfo(token=token)
+ _init_zitadel_client()
+
+ next_url = session.pop('oauth_next', url_for('admin.dashboard'))
+
+ # Token-Exchange & automatische CSRF-State-Prüfung durch Authlib
+ token = oauth.zitadel.authorize_access_token()
+ user_info = oauth.zitadel.userinfo(token=token)
logger.debug(f'OIDC userinfo: {user_info}')
- # Create or update user
user, error = AuthService.process_oidc_callback(user_info)
-
if error:
return jsonify({'error': error}), 400
- # Store in session
+ # Login-State in Flask-Session ablegen
session.clear()
session['user_id'] = user.id
session['user'] = {
@@ -293,7 +198,6 @@ def callback():
session['token_expires'] = token.get('expires_at')
session.permanent = True
- # Log the login
AuditLog.log_action(
action='login',
resource_type='auth',
@@ -301,50 +205,35 @@ def callback():
details={'mode': 'oidc', 'provider': 'zitadel'}
)
- # Redirect to next URL or dashboard
- next_url = session.pop('oauth_next', url_for('admin.dashboard'))
return redirect(next_url)
except Exception as e:
- logger.error(f'OIDC callback failed: {e}')
- return jsonify({'error': 'Authentication failed'}), 500
+ logger.error(f'OIDC callback failed: {e}', exc_info=True)
+ return jsonify({'error': 'Authentication failed', 'details': str(e)}), 500
@auth_bp.route('/logout')
def logout():
- """
- Logout route - clears session and redirects to login.
-
- In production: Also revokes tokens with Zitadel if configured
- In development/mock: Simply clears the session
- """
- # Get current user for audit logging
+ """Logout route - clears session and redirects to login."""
current_user = None
if 'user_id' in session:
current_user = User.query.get(session['user_id'])
- # Determine auth mode for logging
auth_mode = 'mock' if AuthService.is_mock_auth_enabled() else ('dev' if AuthService.is_dev_mode() else 'oidc')
- # Clear session
+ token_to_revoke = session.get('access_token')
session.clear()
- # In production, revoke token with Zitadel
- if not AuthService.is_dev_mode() and 'access_token' in session:
+ if not AuthService.is_dev_mode() and token_to_revoke:
try:
- from authlib.integrations.flask_client import OAuth
- oauth = OAuth(current_app)
- zitadel = oauth.create_client('zitadel')
-
- # Revoke token
- zitadel.revoke_token(
- token=session['access_token'],
+ _init_zitadel_client()
+ oauth.zitadel.revoke_token(
+ token=token_to_revoke,
token_type_hint='access_token'
)
except Exception as e:
logger.warning(f'Failed to revoke token: {e}')
- # Log the logout
if current_user:
AuditLog.log_action(
action='logout',
@@ -358,11 +247,7 @@ def logout():
@auth_bp.route('/status')
def status():
- """
- Check current authentication status.
-
- :return: JSON with current authentication status
- """
+ """Check current authentication status."""
if 'user_id' in session and session.get('authenticated'):
user_data = session.get('user', {})
return jsonify({
@@ -381,13 +266,7 @@ def status():
@auth_bp.route('/userinfo')
def userinfo():
- """
- Get detailed user information.
-
- Requires authentication.
-
- :return: JSON with user information
- """
+ """Get detailed user information."""
if not ('user_id' in session and session.get('authenticated')):
return jsonify({'error': 'Unauthorized'}), 401
@@ -402,4 +281,4 @@ def userinfo():
'tenants': [tenant.to_dict() for tenant in user.tenants],
'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
- })
+ })
\ No newline at end of file
diff --git a/services/auth_service.py b/services/auth_service.py
index ec35abc..f36de44 100644
--- a/services/auth_service.py
+++ b/services/auth_service.py
@@ -87,7 +87,6 @@ class AuthService:
"""
config = current_app.config
- # Use configured dev user settings or defaults
dev_user = {
'id': config.get('DEV_USER_ID', 'user_dev_admin_001'),
'email': config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
@@ -110,7 +109,6 @@ class AuthService:
"""
config = current_app.config
- # Use configured mock user settings or defaults
mock_user = {
'id': config.get('MOCK_USER_ID', 'mock_user_001'),
'email': config.get('MOCK_USER_EMAIL', 'mock@formeleon.local'),
@@ -180,7 +178,8 @@ class AuthService:
@staticmethod
def process_oidc_callback(user_info: dict) -> Tuple[Optional[User], Optional[str]]:
"""
- Process user information from OIDC callback and create/update user.
+ Process user information from OIDC callback, evaluate Zitadel roles,
+ and create/update the local user record.
:param user_info: User information dictionary from OIDC provider
:return: Tuple of (User object or None, error message or None)
@@ -189,11 +188,35 @@ class AuthService:
email = user_info.get('email')
name = user_info.get('name', user_info.get('preferred_username', 'Unknown'))
- if not email:
- return None, 'No email in user info'
+ if not email or not sub:
+ return None, 'Incomplete user info from OIDC provider (missing sub or email).'
- # Create or update user
- user = User.query.filter_by(sub=sub).first()
+ # 1. Zitadel-Rollen extrahieren
+ # Zitadel liefert Projektrollen im Claim 'urn:zitadel:iam:org:project:roles'
+ raw_roles = user_info.get('urn:zitadel:iam:org:project:roles', {})
+
+ if isinstance(raw_roles, dict):
+ user_roles = list(raw_roles.keys())
+ elif isinstance(raw_roles, list):
+ user_roles = raw_roles
+ else:
+ user_roles = []
+
+ logger.debug(f"Extracted OIDC roles for {email}: {user_roles}")
+
+ # 2. Hard Gate: Zugriff verweigern, wenn keine der erlaubt Formeleon-Rollen zugewiesen ist
+ has_admin_role = 'form-admins' in user_roles
+ has_user_role = 'form-users' in user_roles
+
+ if not (has_admin_role or has_user_role):
+ logger.warning(f"Access denied for {email}. Missing required roles ('form-admins' or 'form-users'). Roles present: {user_roles}")
+ return None, 'Access denied: You do not have permission to access Formeleon.'
+
+ # 3. Formeleon-Rolle festlegen (Admin schlägt User)
+ assigned_role = UserRole.ADMIN if has_admin_role else UserRole.USER
+
+ # 4. User suchen oder neu anlegen
+ user = User.query.filter((User.sub == sub) | (User.email == email)).first()
if not user:
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
@@ -201,16 +224,18 @@ class AuthService:
id=user_id,
email=email,
name=name,
- role=UserRole.USER, # Default role for new OIDC users
+ role=assigned_role,
sub=sub
)
db.session.add(user)
- db.session.commit()
- logger.info(f'Created new user from OIDC: {user.id}')
+ logger.info(f'Created new user from OIDC: {user.id} with role {assigned_role.value}')
else:
- # Update existing user
+ # Re-sync Name, Sub und Rolle bei jedem Login
user.name = name
+ user.sub = sub
user.email = email
- db.session.commit()
+ user.role = assigned_role
+ logger.info(f'Updated existing user from OIDC: {user.id} (Role: {assigned_role.value})')
- return user, None
+ db.session.commit()
+ return user, None
\ No newline at end of file
diff --git a/services/user_admin_service.py b/services/user_admin_service.py
index aa9f655..f31d81f 100644
--- a/services/user_admin_service.py
+++ b/services/user_admin_service.py
@@ -1,5 +1,5 @@
from typing import Optional, List
-from models import db, User, UserRole
+from models import db, User, UserRole, Tenant
from models import AuditLog
import logging
@@ -39,3 +39,52 @@ class UserAdminService:
resource_id=target_user.id,
details={'email': target_user.email}
)
+
+ @staticmethod
+ def assign_tenant_to_user(user, tenant, admin_user):
+ """Assign a tenant to a user."""
+ if tenant not in user.tenants:
+ user.tenants.append(tenant)
+ db.session.commit()
+
+ AuditLog.log_action(
+ action='assign_tenant',
+ resource_type='user',
+ user=admin_user,
+ resource_id=user.id,
+ details={'tenant_id': tenant.id, 'tenant_name': tenant.name}
+ )
+ logger.info(f'Assigned user {user.id} to tenant {tenant.id}')
+
+ @staticmethod
+ def remove_tenant_from_user(user, tenant, admin_user):
+ """Remove a tenant from a user."""
+ if tenant in user.tenants:
+ user.tenants.remove(tenant)
+ db.session.commit()
+
+ AuditLog.log_action(
+ action='remove_tenant',
+ resource_type='user',
+ user=admin_user,
+ resource_id=user.id,
+ details={'tenant_id': tenant.id, 'tenant_name': tenant.name}
+ )
+ logger.info(f'Removed user {user.id} from tenant {tenant.id}')
+
+ @staticmethod
+ def update_user_role(user, role, admin_user):
+ """Update a user's role."""
+ if role in [r.value for r in UserRole]:
+ old_role = user.role.value
+ user.role = UserRole(role)
+ db.session.commit()
+
+ AuditLog.log_action(
+ action='update_role',
+ resource_type='user',
+ user=admin_user,
+ resource_id=user.id,
+ details={'old_role': old_role, 'new_role': role}
+ )
+ logger.info(f'Updated user {user.id} role from {old_role} to {role}')
diff --git a/templates/admin/users/list.html b/templates/admin/users/list.html
new file mode 100644
index 0000000..259dcfc
--- /dev/null
+++ b/templates/admin/users/list.html
@@ -0,0 +1,109 @@
+# ==============================================================================
+# Formeleon - Multi-tenant Form Generator
+# Author: Björn Runden
+# Template: admin/users/list.html
+# Description: List all users with filtering and role management.
+# ============================================================================== #}
+
+{% extends "base.html" %}
+
+{% block title %}Users - Formeleon{% endblock %}
+
+{% block content %}
+
+
+
+ {% if users %}
+
+
+
+
+
+
+
+
+
+ | Name |
+ Email |
+ Role |
+ Tenants |
+ Last Login |
+ Created |
+ Actions |
+
+
+
+ {% for u in users %}
+
+ | {{ u.name }} |
+ {{ u.email }} |
+
+
+ {{ u.role.value }}
+
+ |
+ {{ u.tenants|length }} |
+ {{ u.last_login.strftime('%Y-%m-%d %H:%M') if u.last_login else 'Never' }} |
+ {{ u.created_at.strftime('%Y-%m-%d') if u.created_at else 'N/A' }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+ {% else %}
+
+
+
+
+
+
No Users
+
No users have been registered yet.
+
Users are created through OIDC authentication (Zitadel) or in development mode.
+
+
+
+
+ {% endif %}
+
+
+{% if dev_mode %}
+
+
+ Development Mode:
+ User management is available. In production, users are managed through your OIDC provider.
+
+
+{% endif %}
+{% endblock %}
diff --git a/templates/admin/users/tenants.html b/templates/admin/users/tenants.html
new file mode 100644
index 0000000..8f5a539
--- /dev/null
+++ b/templates/admin/users/tenants.html
@@ -0,0 +1,123 @@
+# ==============================================================================
+# Formeleon - Multi-tenant Form Generator
+# Author: Björn Runden
+# Template: admin/users/tenants.html
+# Description: Manage tenant assignments for a user.
+# ============================================================================== #}
+
+{% extends "base.html" %}
+
+{% block title %}Manage Tenants for {{ target_user.name }} - Formeleon{% endblock %}
+
+{% block content %}
+
+
+
+
+
Manage Tenants for {{ target_user.name }}
+
+ Assign or remove tenants from this user's access list.
+
+
+
+
+
+
+
+
+
+
+
+
+ {# Available Tenants #}
+
+
Available Tenants
+
Tenants not yet assigned to this user
+
+ {% if all_tenants %}
+
+ {% for tenant in all_tenants %}
+ {% if tenant not in user_tenants %}
+
+ {% endif %}
+ {% endfor %}
+
+ {% else %}
+
No tenants available to assign.
+ {% endif %}
+
+
+ {# Assigned Tenants #}
+
+
Assigned Tenants
+
Tenants currently assigned to this user
+
+ {% if user_tenants %}
+
+ {% for tenant in user_tenants %}
+
+ {% endfor %}
+
+ {% else %}
+
No tenants assigned to this user.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
Information
+
+ - Admin users have access to all tenants and can manage any user.
+ - Tenant Admin users have access only to tenants they are assigned to.
+ - Regular users can only access tenants they are explicitly assigned to.
+ - Changes to tenant assignments take effect immediately.
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/admin/users/view.html b/templates/admin/users/view.html
new file mode 100644
index 0000000..5818a8e
--- /dev/null
+++ b/templates/admin/users/view.html
@@ -0,0 +1,176 @@
+# ==============================================================================
+# Formeleon - Multi-tenant Form Generator
+# Author: Björn Runden
+# Template: admin/users/view.html
+# Description: View and edit user details with role management.
+# ============================================================================== #}
+
+{% extends "base.html" %}
+
+{% block title %}{{ target_user.name }} - Formeleon{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ target_user.name }}
+
+ Email: {{ target_user.email }} |
+ Role:
+ {{ target_user.role.value }}
+
+
+
+
+
+
+
+
+ {# User Details Card #}
+
+
+
+
+
+ - User ID:
+ {{ target_user.id }}
+
+ - Email:
+ - {{ target_user.email }}
+
+ - Name:
+ - {{ target_user.name }}
+
+ - OIDC Subject:
+ {{ target_user.sub or 'N/A' }}
+
+ - Role:
+ -
+
+
+
+ - Created:
+ - {{ target_user.created_at.strftime('%Y-%m-%d %H:%M:%S') if target_user.created_at else 'N/A' }}
+
+ - Last Updated:
+ - {{ target_user.updated_at.strftime('%Y-%m-%d %H:%M:%S') if target_user.updated_at else 'N/A' }}
+
+ - Last Login:
+ - {{ target_user.last_login.strftime('%Y-%m-%d %H:%M:%S') if target_user.last_login else 'Never' }}
+
+
+
+
+
+ {# Associated Tenants Card #}
+
+
+
+
+ {% if tenants %}
+
+
+
+
+ | Name |
+ ID |
+ Status |
+ Forms |
+
+
+
+ {% for tenant in tenants %}
+
+ | {{ tenant.name }} |
+ {{ tenant.id }} |
+
+
+ {{ tenant.status.value }}
+
+ |
+ {{ tenant.forms|length }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No tenants assigned.
+
+ Assign Tenants
+
+ {% endif %}
+
+
+
+
+
+ {# Edit User Modal #}
+
+
+{% endblock %}