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 %} +
+
+
+

Users

+
+
+ + {% if users %} +
+
+
+
+
All Users ({{ users|length }})
+
+ + +
+
+
+
+ + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
NameEmailRoleTenantsLast LoginCreatedActions
{{ 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' }} +
+ + + + + + +
+ +
+
+
+
+
+
+
+
+ {% 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. +

+
+ +
+
+ +
+
+
+
+
Tenant Assignments
+
+
+
+ {# 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 %} +
+ + +
+
+ {{ tenant.name }} + + {{ tenant.status.value }} + +
{{ tenant.id }}
+
+ +
+
+ {% 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 %} +
+ + +
+
+ {{ tenant.name }} + + {{ tenant.status.value }} + +
{{ tenant.id }}
+
+ +
+
+ {% 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 Details
+
+
+
+
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 #} +
+
+
+
Associated Tenants ({{ tenants|length }})
+ + Manage + +
+
+ {% if tenants %} +
+ + + + + + + + + + + {% for tenant in tenants %} + + + + + + + {% endfor %} + +
NameIDStatusForms
{{ tenant.name }}{{ tenant.id }} + + {{ tenant.status.value }} + + {{ tenant.forms|length }}
+
+ {% else %} +

No tenants assigned.

+ + Assign Tenants + + {% endif %} +
+
+
+
+ + {# Edit User Modal #} + +
+{% endblock %}