From c74bca1e13321e9b7fa6e078515328d5d2a77bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Runden?= Date: Sat, 1 Aug 2026 15:40:30 +0200 Subject: [PATCH] initiales bauen mit mistral --- .env.example | 159 ++++++ .gitignore | 90 ++++ Dockerfile | 53 ++ app.py | 482 ++++++++++++++++++ config.py | 176 +++++++ docker-compose.yml | 83 +++ models.py | 642 +++++++++++++++++++++++ requirements.txt | 36 ++ routes/admin.py | 758 ++++++++++++++++++++++++++++ routes/api.py | 512 +++++++++++++++++++ routes/auth.py | 440 ++++++++++++++++ services/recaptcha.py | 225 +++++++++ static/css/style.css | 357 +++++++++++++ static/js/main.js | 432 ++++++++++++++++ templates/admin/dashboard.html | 282 +++++++++++ templates/admin/forms/list.html | 131 +++++ templates/admin/tenants/create.html | 110 ++++ templates/admin/tenants/list.html | 112 ++++ templates/admin/tenants/view.html | 256 ++++++++++ templates/base.html | 74 +++ templates/index.html | 126 +++++ templates/navbar.html | 106 ++++ 22 files changed, 5642 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 routes/admin.py create mode 100644 routes/api.py create mode 100644 routes/auth.py create mode 100644 services/recaptcha.py create mode 100644 static/css/style.css create mode 100644 static/js/main.js create mode 100644 templates/admin/dashboard.html create mode 100644 templates/admin/forms/list.html create mode 100644 templates/admin/tenants/create.html create mode 100644 templates/admin/tenants/list.html create mode 100644 templates/admin/tenants/view.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/navbar.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d79779 --- /dev/null +++ b/.env.example @@ -0,0 +1,159 @@ +# ============================================================================= +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# File: .env.example +# Description: Environment configuration template. Copy to .env and customize. +# ============================================================================= + +# ============================================================================= +# APPLICATION SETTINGS +# ============================================================================= + +# Flask Environment: development, production, testing +FLASK_ENV=development + +# Secret Key for Flask session management +# Generate with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=your-secret-key-change-in-production + +# Enable/Disable Debug Mode +FLASK_DEBUG=true + +# Development Mode - enables fake auth and mocking +DEV_MODE=true + +# Application port (default: 5000) +# PORT=5000 + +# ============================================================================= +# DATABASE SETTINGS +# ============================================================================= + +# SQLite (default for development) +# SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db + +# PostgreSQL +# SQLALCHEMY_DATABASE_URI=postgresql://username:password@localhost/formeleon + +# MySQL +# SQLALCHEMY_DATABASE_URI=mysql+pymysql://username:password@localhost/formeleon + +# Enable SQL query logging +SQLALCHEMY_ECHO=false + +# ============================================================================= +# ZITADEL OIDC SETTINGS +# ============================================================================= + +# Zitadel Issuer URL (OIDC Provider) +# Example: https://your-zitadel-instance.zitadel.cloud +ZITADEL_ISSUER= + +# OIDC Client ID from Zitadel +ZITADEL_CLIENT_ID= + +# OIDC Client Secret from Zitadel +ZITADEL_CLIENT_SECRET= + +# Redirect URI for OIDC callback +# Default: http://localhost:5000/auth/callback +ZITADEL_REDIRECT_URI=http://localhost:5000/auth/callback + +# OIDC Scopes (comma-separated) +# ZITADEL_SCOPES=openid,profile,email + +# ============================================================================= +# RECAPTCHA SETTINGS +# ============================================================================= + +# Google reCAPTCHA v3 Site Key (for client-side) +RECAPTCHA_SITE_KEY= + +# Google reCAPTCHA v3 Secret Key (for server-side validation) +RECAPTCHA_SECRET_KEY= + +# Minimum score threshold (0.0 - 1.0, default: 0.5) +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 +# ============================================================================= + +# Enable CORS (default: true) +ENABLE_CORS=true + +# Allowed Origins (comma-separated, default: *) +ALLOWED_ORIGINS=* + +# ============================================================================= +# SESSION SETTINGS +# ============================================================================= + +# Session Cookie Secure (HTTPS only, default: false for development) +SESSION_COOKIE_SECURE=false + +# Session Cookie SameSite (Lax, Strict, None) +SESSION_COOKIE_SAMESITE=Lax + +# ============================================================================= +# FILE UPLOAD SETTINGS +# ============================================================================= + +# Maximum upload size in bytes (default: 16MB = 16 * 1024 * 1024) +MAX_CONTENT_LENGTH=16777216 + +# Upload folder +UPLOAD_FOLDER=/tmp/formeleon_uploads + +# Allowed file extensions (comma-separated) +# ALLOWED_EXTENSIONS=png,jpg,jpeg,gif,pdf,txt + +# ============================================================================= +# EMAIL SETTINGS (for notifications) +# ============================================================================= + +# SMTP Server +# SMTP_SERVER=localhost +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_USE_TLS=true +# EMAIL_FROM=formeleon@yourdomain.com + +# ============================================================================= +# LOGGING SETTINGS +# ============================================================================= + +# Log Level: DEBUG, INFO, WARNING, ERROR, CRITICAL +# LOG_LEVEL=INFO + +# ============================================================================= +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c80d54f --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# ============================================================================= +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# File: .gitignore +# Description: Git ignore patterns for Formeleon project. +# ============================================================================= + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Flask +venv/ +ENV/ +env/ +.venv/ + +# Virtual environments +.env +.venv +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Database +*.db +*.sqlite +*.sqlite3 +formeleon.db + +# Logs +*.log +logs/ + +# Environment files (keep .env.example) +.env +.env.local +.env.*.local + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Static files cache +.static/ + +# Uploads (keep directory, ignore contents) +/tmp/formeleon_uploads/* +!/tmp/formeleon_uploads/.gitkeep + +# Migration files +migrations/ + +# Session files +.flask_session* + +# Cache +.cache/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..daf5a10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# ============================================================================= +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# File: Dockerfile +# Description: Docker container configuration for Formeleon application. +# ============================================================================= + +# Use official Python runtime as parent image +# Use official Python 3.13 runtime as parent image +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_ENV=production \ + FLASK_APP=app.py + +# Set working directory +WORKDIR /app + +# Install system dependencies (einschließlich curl für Healthcheck) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements file first to leverage Docker cache +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create uploads directory +RUN mkdir -p /tmp/formeleon_uploads && \ + touch /tmp/formeleon_uploads/.gitkeep + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s \ + CMD curl -f http://localhost:5000/api/v1/status || exit 1 + +# Run the application with gthread workers for high concurrency +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--worker-class", "gthread", "app:app"] + +# For development (uncomment and use docker-compose) +# CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..e4b76c3 --- /dev/null +++ b/app.py @@ -0,0 +1,482 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: app.py +# Description: Main Flask application initialization and Blueprint registration. +# Sets up database, configuration, and registers all route blueprints. +# ============================================================================== + +""" +Main Flask application module for Formeleon. + +This module: +- Loads application configuration +- Initializes Flask extensions (SQLAlchemy, Migrate, CORS, Session) +- Registers all route blueprints (API, Admin, Auth) +- Sets up error handlers +- Provides utility functions for request context + +The application uses Flask Blueprints for modular route organization: +- /api/v1/* - Public Form API for form submissions +- /admin/* - Admin Dashboard for form management +- /auth/* - Authentication routes (OIDC callbacks, logout) +""" + +import os +import logging +from typing import Optional + +from flask import Flask, request, session, g +from flask_cors import CORS +from flask_session import Session +from flask_migrate import Migrate +import uuid + +# corrected for py3.13 from config import Config, config_map +from config import Config, config_map +from models import db, Tenant, User, UserRole, Submission, FormConfig, AuditLog, SubmissionStatus + + +# Initialize extensions globally (will be bound to app in create_app) +migrate = Migrate() +cors = CORS() + + +def get_config_class() -> type: + """ + Get the appropriate configuration class based on FLASK_ENV. + + :return: Configuration class to use + """ + env = os.environ.get('FLASK_ENV', 'development') + return config_map.get(env, Config) + + +def create_app(config_class: Optional[type] = None) -> Flask: + """ + Create and configure the Flask application instance. + + :param config_class: Configuration class to use (defaults to environment-based) + :return: Configured Flask application instance + """ + # Initialize Flask app + app = Flask(__name__) + app.config.from_object(config_class or get_config_class()) + + # Set up logging + setup_logging(app) + + # Initialize extensions with app + initialize_extensions(app) + + # Register blueprints + register_blueprints(app) + + # Register error handlers + register_error_handlers(app) + + # Register CLI commands + register_cli_commands(app) + + # Set up dev mode middleware if enabled + if app.config['DEV_MODE']: + setup_dev_mode_middleware(app) + + return app + + +def setup_logging(app: Flask) -> None: + """ + Configure logging for the application. + + :param app: Flask application instance + """ + log_level = logging.DEBUG if app.config['DEBUG'] else logging.INFO + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + app.logger.setLevel(log_level) + + # Suppress Flask-SQLAlchemy logs unless in debug mode + if not app.config['DEBUG']: + logging.getLogger('flask_sqlalchemy').setLevel(logging.WARNING) + + +def initialize_extensions(app: Flask) -> None: + """ + Initialize Flask extensions with the application. + + :param app: Flask application instance + """ + # Database + db.init_app(app) + + # Migration + migrate.init_app(app, db) + + # CORS + if app.config['ENABLE_CORS']: + cors.init_app(app, resources={ + r'/api/*': { + 'origins': app.config.get('ALLOWED_ORIGINS', '*'), + 'methods': ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + 'allow_headers': ['Content-Type', 'Authorization', 'X-Request-ID'] + } + }) + + # Session + app.config['SESSION_TYPE'] = 'sqlalchemy' + app.config['SESSION_SQLALCHEMY'] = db + Session(app) + + # Ensure secret key is set + if not app.config.get('SECRET_KEY'): + app.config['SECRET_KEY'] = str(uuid.uuid4()) + app.logger.warning('Generated random SECRET_KEY - set explicitly in production!') + + +def register_blueprints(app: Flask) -> None: + """ + Register all route blueprints with the application. + + :param app: Flask application instance + """ + # Import blueprints here to avoid circular imports + from routes.api import api_bp + from routes.admin import admin_bp + from routes.auth import auth_bp + + # Register blueprints with URL prefixes + app.register_blueprint(api_bp, url_prefix='/api/v1') + app.register_blueprint(admin_bp, url_prefix='/admin') + app.register_blueprint(auth_bp, url_prefix='/auth') + + # Register root route for health check + @app.route('/') + def health_check(): + """Health check endpoint.""" + return { + 'status': 'ok', + 'version': '1.0.0', + 'dev_mode': app.config['DEV_MODE'] + }, 200 + + +def register_error_handlers(app: Flask) -> None: + """ + Register custom error handlers for the application. + + :param app: Flask application instance + """ + @app.errorhandler(400) + def bad_request_error(error): + """Handle 400 Bad Request errors.""" + return { + 'error': 'Bad Request', + 'message': str(error.description) if error.description else 'Invalid request data' + }, 400 + + @app.errorhandler(401) + def unauthorized_error(error): + """Handle 401 Unauthorized errors.""" + return { + 'error': 'Unauthorized', + 'message': str(error.description) if error.description else 'Authentication required' + }, 401 + + @app.errorhandler(403) + def forbidden_error(error): + """Handle 403 Forbidden errors.""" + return { + 'error': 'Forbidden', + 'message': str(error.description) if error.description else 'Access denied' + }, 403 + + @app.errorhandler(404) + def not_found_error(error): + """Handle 404 Not Found errors.""" + return { + 'error': 'Not Found', + 'message': str(error.description) if error.description else 'Resource not found' + }, 404 + + @app.errorhandler(429) + def too_many_requests_error(error): + """Handle 429 Too Many Requests errors.""" + return { + 'error': 'Too Many Requests', + 'message': str(error.description) if error.description else 'Rate limit exceeded' + }, 429 + + @app.errorhandler(500) + def internal_server_error(error): + """Handle 500 Internal Server Error.""" + app.logger.error(f'Internal Server Error: {error}') + return { + 'error': 'Internal Server Error', + 'message': 'An unexpected error occurred' + }, 500 + + +def register_cli_commands(app: Flask) -> None: + """ + Register custom CLI commands for the application. + + :param app: Flask application instance + """ + import click + from flask.cli import with_appcontext + + @app.cli.command('seed-db') + @with_appcontext + def seed_db(): + """Seed the database with test data for development.""" + import uuid + + # Check if data already exists + if Tenant.query.first(): + print('Database already contains data. Use --force to overwrite.') + return + + # Create dev tenant + tenant = Tenant( + id='tenant_dev_123', + name='Development Tenant', + public_key='dev-public-key-12345', + allowed_origins='http://localhost:5000,http://localhost:8080', + status=TenantStatus.ACTIVE + ) + db.session.add(tenant) + + # Create sample form + sample_form = FormConfig( + id='form_sample_001', + tenant_id='tenant_dev_123', + name='Contact Form', + description='Sample contact form for testing', + form_structure={ + 'version': '1.0', + 'fields': [ + { + 'name': 'name', + 'type': 'text', + 'label': 'Your Name', + 'required': True, + 'validation': {'min_length': 2, 'max_length': 100} + }, + { + 'name': 'email', + 'type': 'email', + 'label': 'Email Address', + 'required': True, + 'validation': {'pattern': r'^[^@]+@[^@]+\.[^@]+$'} + }, + { + 'name': 'subject', + 'type': 'text', + 'label': 'Subject', + 'required': True + }, + { + 'name': 'message', + 'type': 'textarea', + 'label': 'Message', + 'required': True, + 'validation': {'min_length': 10} + } + ], + 'submit_button': { + 'text': 'Send Message', + 'style': 'primary' + } + }, + settings={ + 'email_notification': { + 'enabled': False, + 'recipients': ['admin@formeleon.local'] + }, + 'success_message': 'Thank you for your message!', + 'recaptcha_enabled': True + } + ) + db.session.add(sample_form) + + # Create dev admin user + admin_user = User( + id='user_dev_admin_001', + email=app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), + name=app.config.get('DEV_USER_NAME', 'Dev Admin'), + role=UserRole.ADMIN, + sub='dev-admin-sub-123' + ) + db.session.add(admin_user) + + # Associate user with tenant + from models import user_tenant_association + db.session.execute( + user_tenant_association.insert().values( + user_id=admin_user.id, + tenant_id=tenant.id + ) + ) + + db.session.commit() + print(f'Seeded database with tenant {tenant.id}, form {sample_form.id}, and user {admin_user.id}') + + @app.cli.command('create-tenant') + @click.argument('name') + @click.argument('public_key') + @click.option('--allowed-origins', default='*', help='Comma-separated allowed origins') + @with_appcontext + def create_tenant(name, public_key, allowed_origins): + """Create a new tenant.""" + tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}' + tenant = Tenant( + id=tenant_id, + name=name, + public_key=public_key, + allowed_origins=allowed_origins + ) + db.session.add(tenant) + db.session.commit() + print(f'Created tenant {tenant_id} with public key {public_key}') + + @app.cli.command('create-user') + @click.argument('email') + @click.argument('name') + @click.option('--role', default='user', help='User role: admin, tenant_admin, user, viewer') + @with_appcontext + def create_user(email, name, role): + """Create a new user.""" + from models import UserRole as UR + role_enum = getattr(UR, role.upper(), UR.USER) + user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}' + user = User( + id=user_id, + email=email, + name=name, + role=role_enum + ) + db.session.add(user) + db.session.commit() + print(f'Created user {user_id} with email {email} and role {role}') + + +def setup_dev_mode_middleware(app: Flask) -> None: + """ + Set up development mode middleware for fake authentication. + + This middleware injects a fake Zitadel user session when DEV_MODE is enabled, + bypassing the need for actual OIDC authentication during development. + + :param app: Flask application instance + """ + from functools import wraps + + @app.before_request + def inject_dev_user(): + """Inject dev user into session if DEV_MODE is enabled.""" + # Skip for static files, auth routes, and API routes + if (request.path.startswith('/static/') or + request.path.startswith('/auth/') or + request.path.startswith('/api/')): + return + + # Skip if user is already authenticated + if 'user_id' in session: + return + + # Inject dev user + dev_user = { + 'id': 'user_dev_admin_001', + 'email': app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), + 'name': app.config.get('DEV_USER_NAME', 'Dev Admin'), + 'role': app.config.get('DEV_USER_ROLE', 'admin'), + 'sub': 'dev-admin-sub-123', + 'tenant_id': app.config.get('DEV_TENANT_ID', 'tenant_dev_123'), + 'dev_mode': True + } + session['user_id'] = dev_user['id'] + session['user'] = dev_user + session.permanent = True + + # Load or create dev user in database + from models import User, UserRole, Tenant + user = User.query.get(dev_user['id']) + if not user: + role = UserRole.ADMIN if dev_user['role'] == 'admin' else UserRole.USER + user = User( + id=dev_user['id'], + email=dev_user['email'], + name=dev_user['name'], + role=role, + sub=dev_user['sub'] + ) + db.session.add(user) + db.session.commit() + + # Ensure dev tenant exists + tenant = Tenant.query.get(dev_user['tenant_id']) + if not tenant: + tenant = Tenant( + id=dev_user['tenant_id'], + name='Development Tenant', + public_key='dev-public-key-12345', + allowed_origins='*' + ) + db.session.add(tenant) + db.session.commit() + + +def get_current_user() -> Optional[User]: + """ + Get the current authenticated user from the request context. + + :return: User object if authenticated, None otherwise + """ + from flask import session + if 'user_id' not in session: + return None + return User.query.get(session['user_id']) + + +def get_current_tenant() -> Optional[Tenant]: + """ + Get the current tenant from the request context. + + :return: Tenant object if available, None otherwise + """ + from flask import session + if 'tenant_id' in session: + return Tenant.query.get(session['tenant_id']) + + user = get_current_user() + if user and user.tenants: + return user.tenants[0] + + return None + + +def get_request_context() -> dict: + """ + Get standardized request context for audit logging. + + :return: Dictionary with request context information + """ + return { + 'ip_address': request.remote_addr, + 'user_agent': request.user_agent.string if request.user_agent else None, + 'path': request.path, + 'method': request.method + } + + +# Create application instance +app = create_app() + + +if __name__ == '__main__': + # This allows running the app directly with python app.py + # In production, use gunicorn or other WSGI servers + app.run(host='0.0.0.0', port=5000, debug=app.config['DEBUG']) diff --git a/config.py b/config.py new file mode 100644 index 0000000..59727ca --- /dev/null +++ b/config.py @@ -0,0 +1,176 @@ +# ============================================================================== +# 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 + + +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', + 'sqlite:///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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..33aa573 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +# ============================================================================= +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# File: docker-compose.yml +# Description: Docker Compose configuration for local development. +# ============================================================================= + +version: '3.8' + +services: + # Formeleon Application + app: + build: + context: . + dockerfile: Dockerfile + container_name: formeleon-app + ports: + - "5000:5000" + volumes: + - .:/app + - ./formeleon.db:/app/formeleon.db + - ./logs:/app/logs + - /tmp/formeleon_uploads:/tmp/formeleon_uploads + environment: + - FLASK_ENV=development + - FLASK_DEBUG=1 + - DEV_MODE=true + - SECRET_KEY=dev-secret-key-change-in-production + - SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db + - ZITADEL_ISSUER= + - ZITADEL_CLIENT_ID= + - ZITADEL_CLIENT_SECRET= + - RECAPTCHA_SITE_KEY= + - RECAPTCHA_SECRET_KEY= + - RECAPTCHA_THRESHOLD=0.5 + - MOCK_RECAPTCHA_SCORE=0.9 + - DEV_USER_ROLE=admin + - DEV_TENANT_ID=tenant_dev_123 + - DEV_USER_NAME=Dev Admin + - DEV_USER_EMAIL=dev@formeleon.local + - ENABLE_CORS=true + - ALLOWED_ORIGINS=* + command: flask run --host=0.0.0.0 --port=5000 + restart: unless-stopped + networks: + - formeleon-network + + # PostgreSQL Database (optional, for production-like development) + # Uncomment to use PostgreSQL instead of SQLite + # postgres: + # image: postgres:15-alpine + # container_name: formeleon-postgres + # environment: + # - POSTGRES_DB=formeleon + # - POSTGRES_USER=formeleon + # - POSTGRES_PASSWORD=formeleon + # volumes: + # - postgres_data:/var/lib/postgresql/data + # ports: + # - "5432:5432" + # restart: unless-stopped + # networks: + # - formeleon-network + + # Redis for Session Storage (optional) + # redis: + # image: redis:7-alpine + # container_name: formeleon-redis + # ports: + # - "6379:6379" + # volumes: + # - redis_data:/data + # restart: unless-stopped + # networks: + # - formeleon-network + +networks: + formeleon-network: + driver: bridge + +# volumes: +# postgres_data: +# redis_data: diff --git a/models.py b/models.py new file mode 100644 index 0000000..8527280 --- /dev/null +++ b/models.py @@ -0,0 +1,642 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: models.py +# Description: SQLAlchemy database models for multi-tenant form management. +# Includes Tenant, FormConfig, Submission, and User models. +# ============================================================================== + +""" +Database models for Formeleon. + +This module defines the core data models using Flask-SQLAlchemy: +- Tenant: Mandant/Client information with public keys and allowed origins +- FormConfig: Dynamic form configurations with JSON structure +- Submission: Form submissions with JSON payload data +- User: Admin users for the management interface +- AuditLog: Tracking of administrative actions + +All models support Flask-Migrate for database migrations. +""" + +import json +from datetime import datetime, timezone +from typing import Optional, Any +from enum import Enum + +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash + +# Initialize SQLAlchemy without binding to a Flask app yet +# This will be bound in app.py after configuration is loaded +db = SQLAlchemy() + + +class UserRole(Enum): + """ + Enumeration of user roles for access control. + + Roles: + ADMIN: Full access to all tenants and configurations + TENANT_ADMIN: Admin access limited to specific tenant(s) + USER: Standard user with form creation/view permissions + VIEWER: Read-only access to forms and submissions + """ + ADMIN = 'admin' + TENANT_ADMIN = 'tenant_admin' + USER = 'user' + VIEWER = 'viewer' + + +class SubmissionStatus(Enum): + """ + Enumeration of submission processing statuses. + + Statuses: + PENDING: Submission received but not yet processed + PROCESSED: Submission has been processed successfully + FAILED: Submission processing failed + SPAM: Submission flagged as spam (reCAPTCHA failed) + """ + PENDING = 'pending' + PROCESSED = 'processed' + FAILED = 'failed' + SPAM = 'spam' + + +class TenantStatus(Enum): + """ + Enumeration of tenant statuses. + + Statuses: + ACTIVE: Tenant is active and can receive submissions + SUSPENDED: Tenant is temporarily suspended + INACTIVE: Tenant is inactive/disabled + """ + ACTIVE = 'active' + SUSPENDED = 'suspended' + INACTIVE = 'inactive' + + +# Association table for many-to-many relationship between User and Tenant +user_tenant_association = db.Table( + 'user_tenant_association', + db.Column('user_id', db.String(64), db.ForeignKey('user.id'), primary_key=True), + db.Column('tenant_id', db.String(64), db.ForeignKey('tenant.id'), primary_key=True), + db.Column('created_at', db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) +) + + +class Tenant(db.Model): + """ + Tenant model representing a client/mandant in the multi-tenant system. + + Each tenant has: + - Unique identifier and name + - Public API key for form submissions + - Allowed origins for CORS/handshake validation + - Status and configuration settings + + Attributes: + id: Unique tenant identifier + name: Display name of the tenant + public_key: API key used by client snippets for authentication + allowed_origins: Comma-separated list of allowed origin domains + status: Current status of the tenant + config: Additional JSON configuration + created_at: Timestamp when tenant was created + updated_at: Timestamp when tenant was last updated + """ + + __tablename__ = 'tenant' + + id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) + name = db.Column(db.String(255), nullable=False) + public_key = db.Column(db.String(128), unique=True, nullable=False) + allowed_origins = db.Column(db.Text, nullable=False, default='*') + status = db.Column(db.Enum(TenantStatus), nullable=False, default=TenantStatus.ACTIVE) + config = db.Column(db.JSON, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False + ) + + # Relationships + forms = db.relationship('FormConfig', backref='tenant', lazy=True, cascade='all, delete-orphan') + submissions = db.relationship('Submission', backref='tenant', lazy=True) + users = db.relationship('User', secondary=user_tenant_association, backref='tenants', lazy=True) + + def __init__(self, id: str, name: str, public_key: str, allowed_origins: str = '*', + status: TenantStatus = TenantStatus.ACTIVE, config: Optional[dict] = None): + """ + Initialize a new Tenant instance. + + :param id: Unique tenant identifier + :param name: Display name of the tenant + :param public_key: API key for client authentication + :param allowed_origins: Comma-separated allowed origins + :param status: Initial tenant status + :param config: Additional configuration as JSON + """ + self.id = id + self.name = name + self.public_key = public_key + self.allowed_origins = allowed_origins + self.status = status + self.config = config or {} + + def get_allowed_origins_list(self) -> list: + """ + Get allowed origins as a list. + + :return: List of allowed origin strings + """ + return [origin.strip() for origin in self.allowed_origins.split(',') if origin.strip()] + + def is_origin_allowed(self, origin: str) -> bool: + """ + Check if a given origin is allowed for this tenant. + + :param origin: Origin to check + :return: True if origin is allowed, False otherwise + """ + allowed = self.get_allowed_origins_list() + if '*' in allowed: + return True + return origin in allowed + + def is_active(self) -> bool: + """ + Check if tenant is active. + + :return: True if tenant status is ACTIVE, False otherwise + """ + return self.status == TenantStatus.ACTIVE + + def to_dict(self) -> dict: + """ + Convert tenant to dictionary for API responses. + + :return: Dictionary representation of tenant + """ + return { + 'id': self.id, + 'name': self.name, + 'public_key': self.public_key, + 'allowed_origins': self.allowed_origins, + 'status': self.status.value, + 'config': self.config or {}, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self) -> str: + return f'' + + +class FormConfig(db.Model): + """ + Form configuration model storing dynamic form definitions. + + Each form configuration has: + - Unique identifier + - Reference to tenant + - Name and description + - JSON structure defining form fields and validation + - Status and settings + + Attributes: + id: Unique form identifier + tenant_id: Foreign key to tenant + name: Display name of the form + description: Optional description + form_structure: JSON defining form fields, types, and validation + settings: Additional form settings (email notifications, etc.) + is_active: Whether the form is active and accepting submissions + created_at: Timestamp when form was created + updated_at: Timestamp when form was last updated + """ + + __tablename__ = 'form_config' + + id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) + tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=True) + form_structure = db.Column(db.JSON, nullable=False) + settings = db.Column(db.JSON, nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False + ) + + # Relationships + submissions = db.relationship('Submission', backref='form_config', lazy=True, cascade='all, delete-orphan') + + def __init__(self, id: str, tenant_id: str, name: str, form_structure: dict, + description: Optional[str] = None, settings: Optional[dict] = None, + is_active: bool = True): + """ + Initialize a new FormConfig instance. + + :param id: Unique form identifier + :param tenant_id: Tenant this form belongs to + :param name: Display name of the form + :param form_structure: JSON defining form fields and structure + :param description: Optional description + :param settings: Additional form settings + :param is_active: Whether form is active + """ + self.id = id + self.tenant_id = tenant_id + self.name = name + self.description = description + self.form_structure = form_structure + self.settings = settings or {} + self.is_active = is_active + + def get_field_definitions(self) -> list: + """ + Get the list of field definitions from form structure. + + :return: List of field definition dictionaries + """ + return self.form_structure.get('fields', []) + + def get_field_names(self) -> list: + """ + Get list of field names from form structure. + + :return: List of field name strings + """ + return [field.get('name') for field in self.get_field_definitions() if field.get('name')] + + def to_dict(self) -> dict: + """ + Convert form config to dictionary for API responses. + + :return: Dictionary representation of form config + """ + return { + 'id': self.id, + 'tenant_id': self.tenant_id, + 'name': self.name, + 'description': self.description, + 'form_structure': self.form_structure, + 'settings': self.settings or {}, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self) -> str: + return f'' + + +class Submission(db.Model): + """ + Submission model storing form submission data. + + Each submission has: + - Unique identifier + - Reference to form configuration + - JSON payload with submitted data + - IP hash for privacy-preserving identification + - Status and submission_metadata + + Attributes: + id: Unique submission identifier + form_config_id: Foreign key to form configuration + tenant_id: Foreign key to tenant (denormalized for easier queries) + payload: JSON containing submitted form data + ip_hash: Hash of submitter's IP address for rate limiting/duplicate detection + user_agent: User agent string of submitter + referer: Referer header from submission request + recaptcha_score: reCAPTCHA score if validation was performed + recaptcha_passed: Whether reCAPTCHA validation passed + status: Current processing status + submission_metadata: Additional metadata (headers, etc.) + created_at: Timestamp when submission was created + """ + + __tablename__ = 'submission' + + id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) + form_config_id = db.Column(db.String(64), db.ForeignKey('form_config.id'), nullable=False) + tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False) + payload = db.Column(db.JSON, nullable=False) + ip_hash = db.Column(db.String(64), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + referer = db.Column(db.Text, nullable=True) + recaptcha_score = db.Column(db.Float, nullable=True) + recaptcha_passed = db.Column(db.Boolean, nullable=True) + status = db.Column(db.Enum(SubmissionStatus), nullable=False, default=SubmissionStatus.PENDING) + submission_metadata = db.Column(db.JSON, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + + def __init__(self, id: str, form_config_id: str, tenant_id: str, payload: dict, + ip_hash: Optional[str] = None, user_agent: Optional[str] = None, + referer: Optional[str] = None, recaptcha_score: Optional[float] = None, + recaptcha_passed: Optional[bool] = None, submission_metadata: Optional[dict] = None, + status: SubmissionStatus = SubmissionStatus.PENDING): + """ + Initialize a new Submission instance. + + :param id: Unique submission identifier + :param form_config_id: Form configuration this submission belongs to + :param tenant_id: Tenant this submission belongs to + :param payload: JSON containing submitted form data + :param ip_hash: Hash of submitter's IP + :param user_agent: User agent string + :param referer: Referer header + :param recaptcha_score: reCAPTCHA score + :param recaptcha_passed: Whether reCAPTCHA passed + :param submission_metadata: Additional submission_metadata + :param status: Initial submission status + """ + self.id = id + self.form_config_id = form_config_id + self.tenant_id = tenant_id + self.payload = payload + self.ip_hash = ip_hash + self.user_agent = user_agent + self.referer = referer + self.recaptcha_score = recaptcha_score + self.recaptcha_passed = recaptcha_passed + self.submission_metadata = submission_metadata or {} + self.status = status + + def get_field_value(self, field_name: str) -> Any: + """ + Get the value of a specific field from the payload. + + :param field_name: Name of the field to retrieve + :return: Value of the field or None if not found + """ + return self.payload.get(field_name) + + def mark_as_processed(self) -> None: + """ + Mark submission as processed. + """ + self.status = SubmissionStatus.PROCESSED + db.session.commit() + + def mark_as_spam(self) -> None: + """ + Mark submission as spam. + """ + self.status = SubmissionStatus.SPAM + db.session.commit() + + def mark_as_failed(self) -> None: + """ + Mark submission as failed. + """ + self.status = SubmissionStatus.FAILED + db.session.commit() + + def to_dict(self) -> dict: + """ + Convert submission to dictionary for API responses. + + :return: Dictionary representation of submission + """ + return { + 'id': self.id, + 'form_config_id': self.form_config_id, + 'tenant_id': self.tenant_id, + 'payload': self.payload, + 'ip_hash': self.ip_hash, + 'user_agent': self.user_agent, + 'referer': self.referer, + 'recaptcha_score': self.recaptcha_score, + 'recaptcha_passed': self.recaptcha_passed, + 'status': self.status.value, + 'submission_metadata': self.submission_metadata or {}, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + def __repr__(self) -> str: + return f'' + + +class User(db.Model): + """ + User model for authentication and authorization. + + Users authenticate via Zitadel OIDC in production, with a dev mode bypass. + Each user has: + - Unique identifier (from OIDC or dev mode) + - Email and name + - Role for access control + - Session management fields + + Attributes: + id: Unique user identifier + email: User email address + name: Display name + role: User role for access control + sub: OIDC subject identifier (from Zitadel) + last_login: Timestamp of last successful login + created_at: Timestamp when user was created + updated_at: Timestamp when user was last updated + """ + + __tablename__ = 'user' + + id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) + name = db.Column(db.String(255), nullable=False) + role = db.Column(db.Enum(UserRole), nullable=False, default=UserRole.USER) + sub = db.Column(db.String(255), unique=True, nullable=True) + last_login = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False + ) + + def __init__(self, id: str, email: str, name: str, role: UserRole = UserRole.USER, + sub: Optional[str] = None): + """ + Initialize a new User instance. + + :param id: Unique user identifier + :param email: User email address + :param name: Display name + :param role: User role + :param sub: OIDC subject identifier + """ + self.id = id + self.email = email + self.name = name + self.role = role + self.sub = sub + + def update_last_login(self) -> None: + """ + Update the last login timestamp to now. + """ + self.last_login = datetime.now(timezone.utc) + db.session.commit() + + def has_role(self, role: UserRole) -> bool: + """ + Check if user has a specific role or higher privilege. + + :param role: Role to check + :return: True if user has the role or a higher-privilege role + """ + role_hierarchy = { + UserRole.ADMIN: 4, + UserRole.TENANT_ADMIN: 3, + UserRole.USER: 2, + UserRole.VIEWER: 1 + } + return role_hierarchy.get(self.role, 0) >= role_hierarchy.get(role, 0) + + def is_admin(self) -> bool: + """ + Check if user has admin privileges. + + :return: True if user is ADMIN or TENANT_ADMIN + """ + return self.role in [UserRole.ADMIN, UserRole.TENANT_ADMIN] + + def to_dict(self) -> dict: + """ + Convert user to dictionary for API responses. + + :return: Dictionary representation of user + """ + return { + 'id': self.id, + 'email': self.email, + 'name': self.name, + 'role': self.role.value, + 'sub': self.sub, + 'last_login': self.last_login.isoformat() if self.last_login else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self) -> str: + return f'' + + +class AuditLog(db.Model): + """ + Audit log model for tracking administrative actions. + + Each audit log entry records: + - Who performed an action + - What action was performed + - On which resource + - With what result + + Attributes: + id: Unique log entry identifier + user_id: User who performed the action (nullable for system actions) + tenant_id: Tenant context (nullable for global actions) + action: Type of action performed + resource_type: Type of resource affected + resource_id: Identifier of resource affected + details: Additional details about the action + ip_address: IP address of requester + user_agent: User agent string + status: Whether action succeeded or failed + created_at: Timestamp when action was logged + """ + + __tablename__ = 'audit_log' + + id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False) + user_id = db.Column(db.String(64), db.ForeignKey('user.id'), nullable=True) + tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=True) + action = db.Column(db.String(64), nullable=False) + resource_type = db.Column(db.String(64), nullable=False) + resource_id = db.Column(db.String(64), nullable=True) + details = db.Column(db.JSON, nullable=True) + ip_address = db.Column(db.String(64), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + status = db.Column(db.String(16), nullable=False, default='success') + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + + def __init__(self, id: str, action: str, resource_type: str, + user_id: Optional[str] = None, tenant_id: Optional[str] = None, + resource_id: Optional[str] = None, details: Optional[dict] = None, + ip_address: Optional[str] = None, user_agent: Optional[str] = None, + status: str = 'success'): + """ + Initialize a new AuditLog instance. + + :param id: Unique log identifier + :param action: Type of action performed + :param resource_type: Type of resource affected + :param user_id: User who performed the action + :param tenant_id: Tenant context + :param resource_id: Resource identifier + :param details: Additional details + :param ip_address: Requester IP address + :param user_agent: Requester user agent + :param status: Action status (success/failure) + """ + self.id = id + self.user_id = user_id + self.tenant_id = tenant_id + self.action = action + self.resource_type = resource_type + self.resource_id = resource_id + self.details = details or {} + self.ip_address = ip_address + self.user_agent = user_agent + self.status = status + + @classmethod + def log_action(cls, action: str, resource_type: str, user: Optional[User] = None, + tenant: Optional[Tenant] = None, resource_id: Optional[str] = None, + details: Optional[dict] = None, request_context: Optional[dict] = None, + status: str = 'success') -> 'AuditLog': + """ + Convenience method to create an audit log entry. + + :param action: Type of action + :param resource_type: Type of resource + :param user: User performing the action + :param tenant: Tenant context + :param resource_id: Resource identifier + :param details: Additional details + :param request_context: Request context (ip_address, user_agent) + :param status: Action status + :return: Created audit log entry + """ + import uuid + log_entry = cls( + id=str(uuid.uuid4()), + action=action, + resource_type=resource_type, + user_id=user.id if user else None, + tenant_id=tenant.id if tenant else None, + resource_id=resource_id, + details=details, + ip_address=request_context.get('ip_address') if request_context else None, + user_agent=request_context.get('user_agent') if request_context else None, + status=status + ) + db.session.add(log_entry) + db.session.commit() + return log_entry + + def __repr__(self) -> str: + return f'' + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e420fa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,36 @@ +# ============================================================================= +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# File: requirements.txt +# Description: Python dependencies for Formeleon application. +# Uses ~= for compatible releases (allows patch updates, blocks minor updates) +# ============================================================================= + +# Flask Core +Flask~=3.0.0 +Flask-CORS~=4.0.0 +Flask-SQLAlchemy~=3.1.0 +Flask-Migrate~=4.0.0 +Flask-Session~=0.8.0 + +# Database +# Mindestens 2.0.36 wegen Python 3.13 Typing-Deprecations +SQLAlchemy>=2.0.36,<2.1.0 +psycopg2-binary>=2.9.9 + +# Authentication +Authlib~=1.3.0 +PyJWT~=2.8.0 + +# HTTP requests +requests~=2.31.0 + +# Environment variables +python-dotenv~=1.0.0 + +# Development +python-dateutil~=2.8.0 + +# Production +gunicorn~=22.0.0 +# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil \ No newline at end of file diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..a375bbd --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,758 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: routes/admin.py +# Description: Admin Dashboard routes for form management. +# Provides CRUD operations for tenants, forms, and submissions. +# ============================================================================== + +""" +Admin Dashboard routes for Formeleon. + +This module provides the management interface for: +- Tenant management (CRUD) +- Form configuration (CRUD) +- Submission viewing and export +- User management +- Dashboard analytics + +Routes: + GET /admin/ - Dashboard overview + GET /admin/tenants/ - List all tenants + GET /admin/tenants/ - View/edit tenant + GET /admin/forms/ - List all forms + GET /admin/forms/ - View/edit form + GET /admin/forms/create - Create new form + GET /admin/submissions/ - List submissions + GET /admin/submissions/ - View submission details + GET /admin/users/ - List users + GET /admin/users/ - View/edit user + +Authentication is handled via the auth middleware or dev mode bypass. +""" + +import uuid +import logging +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone + +from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify +from flask import render_template, flash, abort + +from models import db, Tenant, FormConfig, Submission, User, UserRole, TenantStatus, SubmissionStatus +from models import AuditLog +from services.recaptcha import RecaptchaService + + +logger = logging.getLogger(__name__) + +# Create admin blueprint +admin_bp = Blueprint('admin', __name__, template_folder='../templates', static_folder='../static') + + +def login_required(func): + """Decorator to require login for admin routes.""" + def wrapper(*args, **kwargs): + if not (session.get('user_id') and session.get('authenticated')): + return redirect(url_for('auth.login', next=request.url)) + return func(*args, **kwargs) + wrapper.__name__ = func.__name__ + return wrapper + + +def admin_required(func): + """Decorator to require admin privileges.""" + def wrapper(*args, **kwargs): + if not (session.get('user_id') and session.get('authenticated')): + return redirect(url_for('auth.login', next=request.url)) + + user = User.query.get(session['user_id']) + if not user or not user.is_admin(): + abort(403, description='Admin privileges required') + + return func(*args, **kwargs) + wrapper.__name__ = func.__name__ + return wrapper + + +def get_current_user() -> Optional[User]: + """Get current authenticated user.""" + if 'user_id' in session: + return User.query.get(session['user_id']) + return None + + +def get_current_tenant() -> Optional[Tenant]: + """Get current tenant from session or user context.""" + if 'tenant_id' in session: + return Tenant.query.get(session['tenant_id']) + + user = get_current_user() + if user and user.tenants: + return user.tenants[0] + + return None + + +@admin_bp.before_request +def before_request(): + """Before request handler for admin routes.""" + # Ensure user exists in database if authenticated in session + if session.get('user_id'): + user = User.query.get(session['user_id']) + if not user: + # Clean up invalid session + session.clear() + return redirect(url_for('auth.login')) + + +@admin_bp.route('/') +@login_required +def dashboard(): + """ + Admin dashboard overview. + + Shows: + - Number of tenants + - Number of forms + - Recent submissions + - Quick access links + """ + user = get_current_user() + + # Get statistics + tenant_count = Tenant.query.count() + form_count = FormConfig.query.count() + submission_count = Submission.query.count() + + # Recent submissions + recent_submissions = Submission.query.order_by( + Submission.created_at.desc() + ).limit(10).all() + + # Recent forms + recent_forms = FormConfig.query.order_by( + FormConfig.created_at.desc() + ).limit(5).all() + + # Tenant list + tenants = Tenant.query.order_by(Tenant.created_at.desc()).limit(5).all() + + return render_template('admin/dashboard.html', + user=user, + tenant_count=tenant_count, + form_count=form_count, + submission_count=submission_count, + recent_submissions=recent_submissions, + recent_forms=recent_forms, + tenants=tenants, + dev_mode=current_app.config.get('DEV_MODE', False)) + + +@admin_bp.route('/tenants/') +@login_required +def list_tenants(): + """List all tenants.""" + user = get_current_user() + + # Filter tenants based on user role + if user.role == UserRole.ADMIN: + tenants = Tenant.query.order_by(Tenant.created_at.desc()).all() + else: + # Non-admin users can only see their assigned tenants + tenants = user.tenants if user else [] + + return render_template('admin/tenants/list.html', + user=user, + tenants=tenants, + dev_mode=current_app.config.get('DEV_MODE', False)) + + +@admin_bp.route('/tenants/create', methods=['GET', 'POST']) +@admin_required +def create_tenant(): + """Create a new tenant.""" + user = get_current_user() + + if request.method == 'POST': + # Validate form data + name = request.form.get('name', '').strip() + public_key = request.form.get('public_key', '').strip() + allowed_origins = request.form.get('allowed_origins', '*').strip() + status = request.form.get('status', 'active') + + # Generate tenant ID + tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}' + + # Create tenant + tenant = Tenant( + id=tenant_id, + name=name, + public_key=public_key or str(uuid.uuid4()).hex[:32], + allowed_origins=allowed_origins, + status=TenantStatus(status) if status in [s.value for s in TenantStatus] else TenantStatus.ACTIVE + ) + db.session.add(tenant) + db.session.commit() + + # Audit log + AuditLog.log_action( + action='create', + resource_type='tenant', + user=user, + resource_id=tenant.id, + details={'name': name, 'public_key': public_key} + ) + + flash('Tenant created successfully!', 'success') + return redirect(url_for('admin.list_tenants')) + + return render_template('admin/tenants/create.html', + user=user, + dev_mode=current_app.config.get('DEV_MODE', False)) + + +@admin_bp.route('/tenants/', methods=['GET', 'POST']) +@login_required +def view_tenant(tenant_id): + """View and edit a tenant.""" + user = get_current_user() + tenant = Tenant.query.get(tenant_id) + + if not tenant: + abort(404, description='Tenant not found') + + # Check permissions + if user.role != UserRole.ADMIN and tenant not in user.tenants: + abort(403, description='Access denied') + + if request.method == 'POST': + if not user.is_admin(): + abort(403, description='Admin privileges required to edit tenant') + + # Update tenant + tenant.name = request.form.get('name', tenant.name) + tenant.public_key = request.form.get('public_key', tenant.public_key) + tenant.allowed_origins = request.form.get('allowed_origins', tenant.allowed_origins) + + status = request.form.get('status') + if status in [s.value for s in TenantStatus]: + tenant.status = TenantStatus(status) + + db.session.commit() + + # Audit log + AuditLog.log_action( + action='update', + resource_type='tenant', + user=user, + resource_id=tenant.id, + details={'name': tenant.name, 'status': tenant.status.value} + ) + + flash('Tenant updated successfully!', 'success') + return redirect(url_for('admin.view_tenant', tenant_id=tenant.id)) + + # Get tenant forms + forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by( + FormConfig.created_at.desc() + ).all() + + return render_template('admin/tenants/view.html', + user=user, + tenant=tenant, + forms=forms, + dev_mode=current_app.config.get('DEV_MODE', False), + is_admin=user.is_admin()) + + +@admin_bp.route('/tenants//delete', methods=['POST']) +@admin_required +def delete_tenant(tenant_id): + """Delete a tenant.""" + user = get_current_user() + tenant = Tenant.query.get(tenant_id) + + if not tenant: + abort(404, description='Tenant not found') + + # Delete tenant (cascade will handle related forms and submissions) + db.session.delete(tenant) + db.session.commit() + + # Audit log + AuditLog.log_action( + action='delete', + resource_type='tenant', + user=user, + resource_id=tenant.id, + details={'name': tenant.name} + ) + + flash('Tenant deleted successfully!', 'success') + return redirect(url_for('admin.list_tenants')) + + +@admin_bp.route('/forms/') +@login_required +def list_forms(): + """List all forms for the current tenant/user.""" + user = get_current_user() + tenant = get_current_tenant() + + # Get forms based on user access + if user.role == UserRole.ADMIN: + # Admin can see all forms + forms = FormConfig.query.join(Tenant).order_by( + FormConfig.created_at.desc() + ).all() + elif tenant: + # Tenant user sees only their tenant's forms + forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by( + FormConfig.created_at.desc() + ).all() + else: + forms = [] + + return render_template('admin/forms/list.html', + user=user, + forms=forms, + tenant=tenant, + dev_mode=current_app.config.get('DEV_MODE', False), + is_admin=user.is_admin()) + + +@admin_bp.route('/forms/create', methods=['GET', 'POST']) +@login_required +def create_form(): + """Create a new form.""" + user = get_current_user() + tenant = get_current_tenant() + + if not tenant: + flash('No tenant selected. Please select a tenant.', 'error') + return redirect(url_for('admin.dashboard')) + + if request.method == 'POST': + # Get form data + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + + # Parse form structure from JSON + form_structure_str = request.form.get('form_structure', '{}') + try: + import json + form_structure = json.loads(form_structure_str) + except json.JSONDecodeError: + form_structure = {'version': '1.0', 'fields': []} + + # Parse settings + settings_str = request.form.get('settings', '{}') + try: + settings = json.loads(settings_str) + except json.JSONDecodeError: + settings = {} + + # Generate form ID + form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}' + + # Create form + form = FormConfig( + id=form_id, + tenant_id=tenant.id, + name=name, + description=description, + form_structure=form_structure, + settings=settings, + is_active=request.form.get('is_active', 'true').lower() == 'true' + ) + db.session.add(form) + db.session.commit() + + # Audit log + AuditLog.log_action( + action='create', + resource_type='form', + user=user, + tenant=tenant, + resource_id=form.id, + details={'name': name, 'tenant_id': tenant.id} + ) + + flash('Form created successfully!', 'success') + return redirect(url_for('admin.view_form', form_id=form.id)) + + # Provide default form structure for new forms + default_form_structure = { + 'version': '1.0', + 'fields': [ + { + 'name': 'name', + 'type': 'text', + 'label': 'Your Name', + 'required': True, + 'validation': {'min_length': 2, 'max_length': 100} + }, + { + 'name': 'email', + 'type': 'email', + 'label': 'Email Address', + 'required': True + }, + { + 'name': 'message', + 'type': 'textarea', + 'label': 'Message', + 'required': True + } + ], + 'submit_button': { + 'text': 'Submit', + 'style': 'primary' + } + } + + return render_template('admin/forms/create.html', + user=user, + tenant=tenant, + default_form_structure=default_form_structure, + dev_mode=current_app.config.get('DEV_MODE', False)) + + +@admin_bp.route('/forms/', methods=['GET', 'POST']) +@login_required +def view_form(form_id): + """View and edit a form.""" + user = get_current_user() + form = FormConfig.query.get(form_id) + + if not form: + abort(404, description='Form not found') + + # Check permissions + tenant = Tenant.query.get(form.tenant_id) + if not tenant: + abort(404, description='Tenant not found') + + if user.role != UserRole.ADMIN and tenant not in user.tenants: + abort(403, description='Access denied') + + if request.method == 'POST': + # Update form + form.name = request.form.get('name', form.name) + form.description = request.form.get('description', form.description) + form.is_active = request.form.get('is_active', 'true').lower() == 'true' + + # Update form structure + form_structure_str = request.form.get('form_structure', '{}') + try: + import json + form.form_structure = json.loads(form_structure_str) + except json.JSONDecodeError: + pass # Keep existing structure + + # Update settings + settings_str = request.form.get('settings', '{}') + try: + import json + form.settings = json.loads(settings_str) + except json.JSONDecodeError: + pass # Keep existing settings + + db.session.commit() + + # Audit log + AuditLog.log_action( + action='update', + resource_type='form', + user=user, + tenant=tenant, + resource_id=form.id, + details={'name': form.name, 'is_active': form.is_active} + ) + + flash('Form updated successfully!', 'success') + return redirect(url_for('admin.view_form', form_id=form.id)) + + # Get form submissions + submissions = Submission.query.filter_by(form_config_id=form.id).order_by( + Submission.created_at.desc() + ).limit(10).all() + + return render_template('admin/forms/view.html', + user=user, + form=form, + tenant=tenant, + submissions=submissions, + dev_mode=current_app.config.get('DEV_MODE', False), + is_admin=user.is_admin()) + + +@admin_bp.route('/forms//delete', methods=['POST']) +@login_required +def delete_form(form_id): + """Delete a form.""" + user = get_current_user() + form = FormConfig.query.get(form_id) + + if not form: + abort(404, description='Form not found') + + tenant = Tenant.query.get(form.tenant_id) + if not tenant: + abort(404, description='Tenant not found') + + # Check permissions + if user.role != UserRole.ADMIN and tenant not in user.tenants: + abort(403, description='Access denied') + + # Delete form (cascade will handle submissions) + db.session.delete(form) + db.session.commit() + + # Audit log + AuditLog.log_action( + action='delete', + resource_type='form', + user=user, + tenant=tenant, + resource_id=form.id, + details={'name': form.name} + ) + + flash('Form deleted successfully!', 'success') + return redirect(url_for('admin.list_forms')) + + +@admin_bp.route('/submissions/') +@login_required +def list_submissions(): + """List form submissions.""" + user = get_current_user() + tenant = get_current_tenant() + + # Get submissions based on user access + if user.role == UserRole.ADMIN: + submissions = Submission.query.join(FormConfig).order_by( + Submission.created_at.desc() + ).all() + elif tenant: + submissions = Submission.query.filter_by(tenant_id=tenant.id).order_by( + Submission.created_at.desc() + ).all() + else: + submissions = [] + + return render_template('admin/submissions/list.html', + user=user, + submissions=submissions, + tenant=tenant, + dev_mode=current_app.config.get('DEV_MODE', False), + is_admin=user.is_admin()) + + +@admin_bp.route('/submissions/') +@login_required +def view_submission(submission_id): + """View submission details.""" + user = get_current_user() + submission = Submission.query.get(submission_id) + + if not submission: + abort(404, description='Submission not found') + + # Check permissions + form = FormConfig.query.get(submission.form_config_id) + tenant = Tenant.query.get(submission.tenant_id) + + if not form or not tenant: + abort(404, description='Related form or tenant not found') + + if user.role != UserRole.ADMIN and tenant not in user.tenants: + abort(403, description='Access denied') + + return render_template('admin/submissions/view.html', + user=user, + submission=submission, + form=form, + tenant=tenant, + dev_mode=current_app.config.get('DEV_MODE', False), + is_admin=user.is_admin()) + + +@admin_bp.route('/submissions//status', methods=['POST']) +@login_required +def update_submission_status(submission_id): + """Update submission status.""" + user = get_current_user() + submission = Submission.query.get(submission_id) + + if not submission: + abort(404, description='Submission not found') + + tenant = Tenant.query.get(submission.tenant_id) + if not tenant: + abort(404, description='Tenant not found') + + if user.role != UserRole.ADMIN and tenant not in user.tenants: + abort(403, description='Access denied') + + status = request.form.get('status') + if status in [s.value for s in SubmissionStatus]: + submission.status = SubmissionStatus(status) + db.session.commit() + + # Audit log + AuditLog.log_action( + action='update_status', + resource_type='submission', + user=user, + tenant=tenant, + resource_id=submission.id, + details={'status': status} + ) + + flash('Submission status updated!', 'success') + + return redirect(url_for('admin.view_submission', submission_id=submission.id)) + + +@admin_bp.route('/users/') +@admin_required +def list_users(): + """List all users (admin only).""" + user = get_current_user() + users = User.query.order_by(User.created_at.desc()).all() + + return render_template('admin/users/list.html', + user=user, + users=users, + dev_mode=current_app.config.get('DEV_MODE', False)) + + +@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() + target_user = User.query.get(user_id) + + if not target_user: + abort(404, description='User not found') + + if request.method == 'POST': + # Update user + target_user.email = request.form.get('email', target_user.email) + target_user.name = request.form.get('name', target_user.name) + + role = request.form.get('role') + if role in [r.value for r in UserRole]: + target_user.role = UserRole(role) + + db.session.commit() + + # Audit log + AuditLog.log_action( + action='update', + resource_type='user', + user=user, + resource_id=target_user.id, + details={'email': target_user.email, 'role': target_user.role.value} + ) + + flash('User updated successfully!', 'success') + return redirect(url_for('admin.view_user', user_id=target_user.id)) + + # Get user tenants + tenants = target_user.tenants + + return render_template('admin/users/view.html', + user=user, + target_user=target_user, + tenants=tenants, + dev_mode=current_app.config.get('DEV_MODE', False), + UserRole=UserRole) + + +@admin_bp.route('/users//delete', methods=['POST']) +@admin_required +def delete_user(user_id): + """Delete a user (admin only).""" + user = get_current_user() + target_user = User.query.get(user_id) + + if not target_user: + abort(404, description='User not found') + + # Don't allow deleting self + if target_user.id == user.id: + flash('Cannot delete your own account!', 'error') + return redirect(url_for('admin.view_user', user_id=target_user.id)) + + db.session.delete(target_user) + db.session.commit() + + # Audit log + AuditLog.log_action( + action='delete', + resource_type='user', + user=user, + resource_id=target_user.id, + details={'email': target_user.email} + ) + + flash('User deleted successfully!', 'success') + return redirect(url_for('admin.list_users')) + + +@admin_bp.route('/profile') +@login_required +def profile(): + """User profile page.""" + user = get_current_user() + + if not user: + return redirect(url_for('auth.login')) + + return render_template('admin/profile.html', + user=user, + dev_mode=current_app.config.get('DEV_MODE', False)) + + +@admin_bp.route('/api/recaptcha-config') +@login_required +def get_recaptcha_config(): + """Get reCAPTCHA configuration for client-side rendering.""" + recaptcha_service = RecaptchaService() + + return jsonify({ + 'enabled': recaptcha_service.is_enabled(), + 'site_key': recaptcha_service.get_site_key(), + 'dev_mode': recaptcha_service.is_dev_mode(), + 'mock_score': recaptcha_service.get_dev_mode_score() if recaptcha_service.is_dev_mode() else None + }) + + +@admin_bp.route('/settings') +@admin_required +def settings(): + """Application settings (admin only).""" + user = get_current_user() + + # Get system statistics + tenant_count = Tenant.query.count() + user_count = User.query.count() + form_count = FormConfig.query.count() + submission_count = Submission.query.count() + + # Get audit logs + audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(50).all() + + return render_template('admin/settings.html', + user=user, + tenant_count=tenant_count, + user_count=user_count, + form_count=form_count, + submission_count=submission_count, + audit_logs=audit_logs, + dev_mode=current_app.config.get('DEV_MODE', False)) diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..bc8c710 --- /dev/null +++ b/routes/api.py @@ -0,0 +1,512 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: routes/api.py +# Description: Public Form API for handling form submissions from client websites. +# Provides endpoints for form handshake, submission, and validation. +# ============================================================================== + +""" +Public Form API for Formeleon. + +This module provides the public API endpoints for: +- Client handshake and token exchange +- Form submission processing +- reCAPTCHA validation +- Form configuration retrieval + +All endpoints are prefixed with /api/v1/ and are designed to be +consumed by JavaScript snippets embedded in client websites. + +Authentication: +- Public key handshake for tenant identification +- Origin/Referer validation +- reCAPTCHA v3 token validation (optional) + +Routes: + POST /api/v1/handshake - Initiate handshake and get form config + POST /api/v1/submit/ - Submit form data + GET /api/v1/form/ - Get form configuration + POST /api/v1/validate/recaptcha - Validate reCAPTCHA token +""" + +import hashlib +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional, Dict, Any + +from flask import Blueprint, request, jsonify, current_app +from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound + +from models import db, Tenant, FormConfig, Submission, SubmissionStatus +from services.recaptcha import RecaptchaService + + +logger = logging.getLogger(__name__) + +# Create API blueprint +api_bp = Blueprint('api', __name__) + + +def validate_origin(origin: str, tenant: Tenant) -> bool: + """ + Validate that the request origin matches allowed origins for the tenant. + + :param origin: Request origin/referer + :param tenant: Tenant object to check against + :return: True if origin is allowed, False otherwise + """ + if not origin: + return False + + # Remove trailing slash and protocol + origin = origin.rstrip('/') + + allowed_origins = tenant.get_allowed_origins_list() + + # Check for wildcard + if '*' in allowed_origins: + return True + + # Exact match + if origin in allowed_origins: + return True + + # Check if origin matches any allowed domain (with subdomains) + for allowed in allowed_origins: + if allowed.startswith('*'): + # Wildcard subdomain match + domain = allowed[1:] # Remove leading * + if origin.endswith(domain): + return True + + return False + + +def get_tenant_by_public_key(public_key: str) -> Optional[Tenant]: + """ + Get tenant by public key. + + :param public_key: Public API key + :return: Tenant object or None + """ + return Tenant.query.filter_by(public_key=public_key).first() + + +def hash_ip(ip: str) -> str: + """ + Create a hash of an IP address for privacy-preserving storage. + + :param ip: IP address string + :return: SHA256 hash of the IP + """ + if not ip: + return '' + return hashlib.sha256(ip.encode()).hexdigest() + + +@api_bp.before_request +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_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 jsonify({'status': 'ok'}), 200 + + +@api_bp.errorhandler(BadRequest) +def handle_bad_request(error): + """Handle bad request errors.""" + return jsonify({ + 'error': 'bad_request', + 'message': str(error.description) if error.description else 'Invalid request' + }), 400 + + +@api_bp.errorhandler(Unauthorized) +def handle_unauthorized(error): + """Handle unauthorized errors.""" + return jsonify({ + 'error': 'unauthorized', + 'message': str(error.description) if error.description else 'Authentication required' + }), 401 + + +@api_bp.errorhandler(Forbidden) +def handle_forbidden(error): + """Handle forbidden errors.""" + return jsonify({ + 'error': 'forbidden', + 'message': str(error.description) if error.description else 'Access denied' + }), 403 + + +@api_bp.errorhandler(NotFound) +def handle_not_found(error): + """Handle not found errors.""" + return jsonify({ + 'error': 'not_found', + 'message': str(error.description) if error.description else 'Resource not found' + }), 404 + + +@api_bp.route('/handshake', methods=['POST']) +def handshake(): + """ + Handshake endpoint for client snippets. + + Validates the client's public key and origin, then returns + the form configuration for rendering. + + Request Headers: + X-Public-Key: Tenant's public API key + Origin: Client origin URL + Referer: Client referer URL (fallback) + + Request Body: + form_id: The ID of the form to retrieve + + :return: JSON with handshake token and form configuration + """ + # Get public key from header or form data + public_key = request.headers.get('X-Public-Key') or request.form.get('public_key') + + if not public_key: + raise BadRequest('Public key required') + + # Get origin/referer + origin = request.headers.get('Origin') or request.headers.get('Referer') or '' + + # Find tenant by public key + tenant = get_tenant_by_public_key(public_key) + + if not tenant: + logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...') + raise Unauthorized('Invalid public key') + + # Validate origin + if not tenant.is_active(): + raise Forbidden('Tenant is not active') + + if not validate_origin(origin, tenant): + logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}') + raise Forbidden('Origin not allowed for this tenant') + + # Get form ID from request + form_id = request.form.get('form_id') or request.json.get('form_id') if request.json else None + + if not form_id: + raise BadRequest('form_id is required') + + # Get form configuration + form = FormConfig.query.filter_by( + id=form_id, + tenant_id=tenant.id + ).first() + + if not form: + raise NotFound('Form not found or does not belong to tenant') + + if not form.is_active: + raise Forbidden('Form is not active') + + # Generate handshake token (can be used for subsequent requests) + handshake_token = str(uuid.uuid4()) + + # Return handshake response + response = { + 'status': 'success', + 'tenant_id': tenant.id, + 'form_id': form.id, + 'form_name': form.name, + 'form_config': form.form_structure, + 'settings': form.settings or {}, + 'handshake_token': handshake_token, + 'recaptcha_enabled': form.settings.get('recaptcha_enabled', True) if form.settings else True + } + + logger.info(f'Handshake successful for tenant {tenant.id}, form {form.id}') + + return jsonify(response) + + +@api_bp.route('/form/', methods=['GET']) +def get_form(form_id): + """ + Get form configuration. + + :param form_id: ID of the form to retrieve + :return: JSON with form configuration + """ + # Get public key from header + public_key = request.headers.get('X-Public-Key') + + if not public_key: + raise BadRequest('Public key required') + + # Find tenant by public key + tenant = get_tenant_by_public_key(public_key) + + if not tenant: + raise Unauthorized('Invalid public key') + + if not tenant.is_active(): + raise Forbidden('Tenant is not active') + + # Get form + form = FormConfig.query.filter_by( + id=form_id, + tenant_id=tenant.id + ).first() + + if not form: + raise NotFound('Form not found') + + if not form.is_active: + raise Forbidden('Form is not active') + + return jsonify({ + 'status': 'success', + 'form': form.to_dict() + }) + + +@api_bp.route('/submit/', methods=['POST']) +def submit_form(form_id): + """ + Submit form data. + + Handles form submissions from client websites with: + - Origin validation + - Public key authentication + - reCAPTCHA validation (optional) + - Data validation against form structure + + :param form_id: ID of the form being submitted + :return: JSON with submission result + """ + # Get public key from header + public_key = request.headers.get('X-Public-Key') + + if not public_key: + raise BadRequest('Public key required') + + # Find tenant by public key + tenant = get_tenant_by_public_key(public_key) + + if not tenant: + logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...') + raise Unauthorized('Invalid public key') + + if not tenant.is_active(): + raise Forbidden('Tenant is not active') + + # Validate origin + origin = request.headers.get('Origin') or request.headers.get('Referer') or '' + if not validate_origin(origin, tenant): + logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}') + raise Forbidden('Origin not allowed') + + # Get form + form = FormConfig.query.filter_by( + id=form_id, + tenant_id=tenant.id + ).first() + + if not form: + raise NotFound('Form not found') + + if not form.is_active: + raise Forbidden('Form is not active') + + # Get form data from request + try: + if request.content_type == 'application/json': + payload = request.get_json() + else: + payload = request.form.to_dict() + except Exception as e: + logger.error(f'Failed to parse form data: {e}') + raise BadRequest('Invalid form data format') + + if not payload: + raise BadRequest('No form data provided') + + # Validate reCAPTCHA if enabled + recaptcha_score = None + recaptcha_passed = None + + if form.settings.get('recaptcha_enabled', True) if form.settings else True: + recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') + + if recaptcha_token: + recaptcha_service = RecaptchaService() + is_valid, score = recaptcha_service.verify( + recaptcha_token, + request.remote_addr + ) + recaptcha_score = score + recaptcha_passed = is_valid + + if not is_valid: + logger.warning(f'Submission rejected: reCAPTCHA failed with score {score}') + else: + # If no token provided, use dev mode score if in dev mode + if current_app.config.get('DEV_MODE', False): + recaptcha_score = current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9) + recaptcha_passed = True + + # Create submission + submission_id = f'sub_{form_id}_{str(uuid.uuid4())[:8]}' + + submission = Submission( + id=submission_id, + form_config_id=form.id, + tenant_id=tenant.id, + payload=payload, + ip_hash=hash_ip(request.remote_addr), + user_agent=request.user_agent.string if request.user_agent else None, + referer=origin, + recaptcha_score=recaptcha_score, + recaptcha_passed=recaptcha_passed, + submission_metadata={ + 'origin': origin, + 'user_agent': request.user_agent.string if request.user_agent else None, + 'content_type': request.content_type + } + ) + + # Set status based on reCAPTCHA + if recaptcha_passed is False: + submission.status = SubmissionStatus.SPAM + else: + submission.status = SubmissionStatus.PENDING + + db.session.add(submission) + db.session.commit() + + logger.info(f'Form submission: {submission_id} for form {form_id}, tenant {tenant.id}') + + # Prepare response + response = { + 'status': 'success', + 'submission_id': submission.id, + 'message': form.settings.get('success_message', 'Form submitted successfully!') if form.settings else 'Form submitted successfully!' + } + + # If reCAPTCHA failed, indicate in response + if submission.status == SubmissionStatus.SPAM: + response['status'] = 'spam' + response['message'] = 'Submission flagged as spam' + logger.warning(f'Spam submission: {submission_id}') + + return jsonify(response) + + +@api_bp.route('/validate/recaptcha', methods=['POST']) +def validate_recaptcha(): + """ + Validate a reCAPTCHA token. + + This endpoint can be used by client snippets to pre-validate + reCAPTCHA tokens before form submission. + + :return: JSON with validation result + """ + recaptcha_token = request.form.get('token') or request.json.get('token') if request.json else None + + if not recaptcha_token: + raise BadRequest('reCAPTCHA token required') + + recaptcha_service = RecaptchaService() + + if not recaptcha_service.is_enabled(): + # If reCAPTCHA not configured, return success in dev mode + if current_app.config.get('DEV_MODE', False): + return jsonify({ + 'status': 'success', + 'score': current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9), + 'passed': True, + 'dev_mode': True + }) + else: + raise BadRequest('reCAPTCHA not configured') + + is_valid, score = recaptcha_service.verify( + recaptcha_token, + request.remote_addr + ) + + return jsonify({ + 'status': 'success', + 'score': score, + 'passed': is_valid, + 'threshold': recaptcha_service.get_threshold(), + 'dev_mode': recaptcha_service.is_dev_mode() + }) + + +@api_bp.route('/status') +def api_status(): + """ + API health check endpoint. + + :return: JSON with API status + """ + return jsonify({ + 'status': 'ok', + 'version': '1.0.0', + 'dev_mode': current_app.config.get('DEV_MODE', False), + 'timestamp': datetime.now(timezone.utc).isoformat() + }) + + +@api_bp.route('/stats/') +def get_tenant_stats(tenant_id): + """ + Get statistics for a tenant. + + :param tenant_id: Tenant ID + :return: JSON with tenant statistics + """ + # Authenticate with public key + public_key = request.headers.get('X-Public-Key') + + if not public_key: + raise BadRequest('Public key required') + + tenant = get_tenant_by_public_key(public_key) + + if not tenant or tenant.id != tenant_id: + raise Unauthorized('Invalid public key or tenant mismatch') + + if not tenant.is_active(): + raise Forbidden('Tenant is not active') + + # Get stats + form_count = FormConfig.query.filter_by(tenant_id=tenant.id).count() + submission_count = Submission.query.filter_by(tenant_id=tenant.id).count() + + # Recent submissions + recent_submissions = Submission.query.filter_by( + tenant_id=tenant.id + ).order_by(Submission.created_at.desc()).limit(5).all() + + return jsonify({ + 'status': 'success', + 'tenant_id': tenant.id, + 'tenant_name': tenant.name, + 'stats': { + 'forms': form_count, + 'submissions': submission_count, + 'recent_submissions': [s.to_dict() for s in recent_submissions] + } + }) diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..a63d2ef --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,440 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: routes/auth.py +# Description: Authentication routes for Zitadel OIDC integration. +# Provides login, logout, and callback handlers with dev mode bypass. +# ============================================================================== + +""" +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 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 models import db, User, UserRole, Tenant, AuditLog + + +logger = logging.getLogger(__name__) + +# Create auth blueprint +auth_bp = Blueprint('auth', __name__, template_folder='../templates') + + +def is_dev_mode() -> bool: + """Check if application is in development mode.""" + return current_app.config.get('DEV_MODE', False) + + +def create_dev_user_session() -> dict: + """ + Create a development mode user session with fake Zitadel user. + + :return: Dictionary with user session data + """ + 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'), + 'name': config.get('DEV_USER_NAME', 'Dev Admin'), + 'role': config.get('DEV_USER_ROLE', 'admin'), + 'sub': f'dev-{uuid.uuid4()}', + 'tenant_id': config.get('DEV_TENANT_ID', 'tenant_dev_123'), + 'dev_mode': True, + 'authenticated': True + } + + return dev_user + + +def ensure_dev_user_exists() -> User: + """ + Ensure the development user exists in the database. + + :return: User object from database + """ + config = current_app.config + + user_id = config.get('DEV_USER_ID', 'user_dev_admin_001') + user = User.query.get(user_id) + + if not user: + role = UserRole.ADMIN if config.get('DEV_USER_ROLE', 'admin') == 'admin' else UserRole.USER + user = User( + id=user_id, + email=config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), + name=config.get('DEV_USER_NAME', 'Dev Admin'), + role=role, + sub=f'dev-{uuid.uuid4()}' + ) + db.session.add(user) + db.session.commit() + logger.info(f'Created dev user: {user.id}') + + return user + + +def ensure_dev_tenant_exists() -> Tenant: + """ + Ensure the development tenant exists in the database. + + :return: Tenant object from database + """ + config = current_app.config + + tenant_id = config.get('DEV_TENANT_ID', 'tenant_dev_123') + tenant = Tenant.query.get(tenant_id) + + if not tenant: + tenant = Tenant( + id=tenant_id, + name='Development Tenant', + public_key=f'dev-key-{uuid.uuid4().hex[:16]}', + allowed_origins='*' + ) + db.session.add(tenant) + db.session.commit() + logger.info(f'Created dev tenant: {tenant.id}') + + return tenant + + +@auth_bp.before_request +def check_dev_mode_auth(): + """ + Middleware to handle development mode authentication. + + If DEV_MODE is enabled and user is not authenticated, + automatically create a dev user session. + """ + # Skip for auth routes themselves + 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 is_dev_mode(): + dev_user = 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 + ensure_dev_user_exists() + ensure_dev_tenant_exists() + + +@auth_bp.route('/login') +def login(): + """ + Login route - initiates OIDC flow or handles dev mode. + + In production: Redirects to Zitadel for OIDC authentication + In development: Creates fake session and redirects to admin + + Query parameters: + next: URL to redirect to after successful login + """ + next_url = request.args.get('next', url_for('admin.dashboard')) + + # Development mode: create fake session and redirect + if is_dev_mode(): + dev_user = create_dev_user_session() + session.clear() + session['user_id'] = dev_user['id'] + session['user'] = dev_user + session['authenticated'] = True + session.permanent = True + + # Ensure user and tenant exist in database + ensure_dev_user_exists() + ensure_dev_tenant_exists() + + logger.info(f'Dev mode login: {dev_user["email"]}') + + AuditLog.log_action( + action='login', + resource_type='auth', + user=User.query.get(dev_user['id']), + 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']: + 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: + zitadel_config = { + 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'), + 'client_secret': current_app.config.get('ZITADEL_CLIENT_SECRET'), + 'authorize_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize", + 'access_token_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/token", + 'userinfo_endpoint': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/userinfo", + 'client_kwargs': { + 'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])) + } + } + oauth.register('zitadel', **zitadel_config) + + # Generate state for CSRF protection + state = str(uuid.uuid4()) + session['oauth_state'] = state + session['oauth_next'] = next_url + + # Build authorization URL + redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI', + url_for('auth.callback', _external=True)) + + authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v1/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) + + +@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. + """ + if 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 + 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) + + logger.debug(f'OIDC userinfo: {user_info}') + + # Extract user data + sub = user_info.get('sub') + email = user_info.get('email') + name = user_info.get('name', user_info.get('preferred_username', 'Unknown')) + + if not email: + return jsonify({'error': 'No email in user info'}), 400 + + # Create or update user + user = User.query.filter_by(sub=sub).first() + + if not user: + user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}' + user = User( + id=user_id, + email=email, + name=name, + role=UserRole.USER, # Default role for new OIDC users + sub=sub + ) + db.session.add(user) + db.session.commit() + logger.info(f'Created new user from OIDC: {user.id}') + else: + # Update existing user + user.name = name + user.email = email + db.session.commit() + + # Store in session + session.clear() + session['user_id'] = user.id + session['user'] = { + 'id': user.id, + 'email': user.email, + 'name': user.name, + 'role': user.role.value, + 'sub': user.sub, + 'authenticated': True, + 'dev_mode': False + } + session['authenticated'] = True + session['access_token'] = token['access_token'] + session['refresh_token'] = token.get('refresh_token') + session['token_expires'] = token.get('expires_at') + session.permanent = True + + # Log the login + AuditLog.log_action( + action='login', + resource_type='auth', + user=user, + 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 + + +@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: Simply clears the session + """ + # Get current user for audit logging + current_user = None + if 'user_id' in session: + current_user = User.query.get(session['user_id']) + + # Clear session + session.clear() + + # In production, revoke token with Zitadel + if not is_dev_mode() and 'access_token' in session: + 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'], + 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', + resource_type='auth', + user=current_user, + details={'mode': 'dev' if is_dev_mode() else 'oidc'} + ) + + return redirect(url_for('auth.login')) + + +@auth_bp.route('/status') +def status(): + """ + Check current authentication status. + + :return: JSON with current authentication status + """ + if 'user_id' in session and session.get('authenticated'): + user_data = session.get('user', {}) + return jsonify({ + 'authenticated': True, + 'user': user_data, + 'dev_mode': is_dev_mode() + }) + + return jsonify({ + 'authenticated': False, + 'dev_mode': is_dev_mode() + }) + + +@auth_bp.route('/userinfo') +def userinfo(): + """ + Get detailed user information. + + Requires authentication. + + :return: JSON with user information + """ + if not ('user_id' in session and session.get('authenticated')): + return jsonify({'error': 'Unauthorized'}), 401 + + user_id = session['user_id'] + user = User.query.get(user_id) + + if not user: + return jsonify({'error': 'User not found'}), 404 + + return jsonify({ + 'user': user.to_dict(), + 'tenants': [tenant.to_dict() for tenant in user.tenants], + 'dev_mode': is_dev_mode() + }) diff --git a/services/recaptcha.py b/services/recaptcha.py new file mode 100644 index 0000000..b066087 --- /dev/null +++ b/services/recaptcha.py @@ -0,0 +1,225 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: services/recaptcha.py +# Description: reCAPTCHA v3 validation service with development mode mocking. +# Provides score-based spam protection for form submissions. +# ============================================================================== + +""" +reCAPTCHA v3 validation service for Formeleon. + +This module provides: +- reCAPTCHA token verification against Google's API +- Development mode mocking with configurable scores +- Score threshold configuration +- Integration with Flask application context + +In development mode (DEV_MODE=true), the service returns mock scores +without contacting Google's API, allowing local testing without API keys. + +Usage: + from services.recaptcha import RecaptchaService + + # In a route handler: + recaptcha_service = RecaptchaService(current_app._get_current_object()) + is_valid, score = recaptcha_service.verify(token) + + if is_valid: + # Process form submission + pass + else: + # Reject as spam + pass +""" + +import requests +import logging +from typing import Optional, Tuple +from flask import current_app + + +logger = logging.getLogger(__name__) + + +class RecaptchaService: + """ + Service for validating reCAPTCHA v3 tokens. + + This service handles: + - Token verification with Google's reCAPTCHA API + - Development mode mocking + - Score threshold evaluation + - Error handling and logging + + Attributes: + app: Flask application instance + secret_key: reCAPTCHA secret key from configuration + site_key: reCAPTCHA site key from configuration + threshold: Minimum score threshold for passing validation + mock_score: Score to return in development mode + verify_url: Google reCAPTCHA verification endpoint + """ + + def __init__(self, app=None): + """ + Initialize the reCAPTCHA service. + + :param app: Flask application instance (optional, can use current_app) + """ + self.app = app or current_app._get_current_object() + self.secret_key = self.app.config.get('RECAPTCHA_SECRET_KEY') + self.site_key = self.app.config.get('RECAPTCHA_SITE_KEY') + self.threshold = float(self.app.config.get('RECAPTCHA_THRESHOLD', 0.5)) + self.mock_score = float(self.app.config.get('MOCK_RECAPTCHA_SCORE', 0.9)) + self.verify_url = self.app.config.get( + 'RECAPTCHA_VERIFY_URL', + 'https://www.google.com/recaptcha/api/siteverify' + ) + self.timeout = 5 # seconds for API requests + + def is_enabled(self) -> bool: + """ + Check if reCAPTCHA is enabled (keys configured). + + :return: True if both secret and site keys are configured + """ + return (self.secret_key is not None and + self.site_key is not None) + + def is_dev_mode(self) -> bool: + """ + Check if application is in development mode. + + :return: True if DEV_MODE is enabled in configuration + """ + return self.app.config.get('DEV_MODE', False) + + def verify(self, token: str, remote_ip: Optional[str] = None) -> Tuple[bool, Optional[float]]: + """ + Verify a reCAPTCHA v3 token. + + :param token: reCAPTCHA response token from client + :param remote_ip: Optional client IP address for verification + :return: Tuple of (is_valid, score) where is_valid indicates if score >= threshold + """ + if not token: + logger.warning('reCAPTCHA verification: No token provided') + return False, None + + # Development mode: return mock score + if self.is_dev_mode(): + logger.debug(f'reCAPTCHA dev mode: Returning mock score {self.mock_score}') + return self.mock_score >= self.threshold, self.mock_score + + # Production mode: verify with Google API + if not self.is_enabled(): + logger.warning('reCAPTCHA verification: Service not configured (missing keys)') + # In production, if not configured, we might want to fail-open or fail-closed + # For security, we fail-closed (treat as spam) + return False, None + + return self._verify_with_google(token, remote_ip) + + def _verify_with_google(self, token: str, remote_ip: Optional[str] = None) -> Tuple[bool, Optional[float]]: + """ + Verify token with Google's reCAPTCHA API. + + :param token: reCAPTCHA response token + :param remote_ip: Client IP address + :return: Tuple of (is_valid, score) + """ + try: + data = { + 'secret': self.secret_key, + 'response': token + } + if remote_ip: + data['remoteip'] = remote_ip + + logger.debug(f'reCAPTCHA: Verifying token with Google API') + + response = requests.post( + self.verify_url, + data=data, + timeout=self.timeout + ) + response.raise_for_status() + + result = response.json() + + if not result.get('success', False): + error_codes = result.get('error-codes', []) + logger.warning(f'reCAPTCHA verification failed: {error_codes}') + return False, None + + score = float(result.get('score', 0)) + action = result.get('action', 'unknown') + + logger.debug(f'reCAPTCHA: Score={score}, Action={action}') + + return score >= self.threshold, score + + except requests.exceptions.Timeout: + logger.error('reCAPTCHA: Verification request timed out') + # Fail-safe: allow submission on timeout to avoid blocking legitimate users + return True, None + except requests.exceptions.RequestException as e: + logger.error(f'reCAPTCHA: Verification request failed: {e}') + # Fail-safe: allow submission on network errors + return True, None + except (ValueError, TypeError, KeyError) as e: + logger.error(f'reCAPTCHA: Invalid response format: {e}') + return False, None + + def get_site_key(self) -> str: + """ + Get the reCAPTCHA site key for client-side rendering. + + :return: Site key string + """ + return self.site_key or '' + + def get_threshold(self) -> float: + """ + Get the current score threshold. + + :return: Threshold value + """ + return self.threshold + + def get_dev_mode_score(self) -> float: + """ + Get the mock score used in development mode. + + :return: Mock score value + """ + return self.mock_score + + +# Singleton instance for convenience (can be used when app context is available) +_recaptcha_service: Optional[RecaptchaService] = None + + +def get_recaptcha_service() -> RecaptchaService: + """ + Get the reCAPTCHA service instance. + + Creates a new instance on first call, then returns the same instance. + + :return: RecaptchaService instance + """ + global _recaptcha_service + if _recaptcha_service is None: + _recaptcha_service = RecaptchaService() + return _recaptcha_service + + +def reset_recaptcha_service() -> None: + """ + Reset the reCAPTCHA service singleton. + + Useful for testing or when configuration changes. + """ + global _recaptcha_service + _recaptcha_service = None diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..ace4e99 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,357 @@ +/* ============================================================================= + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Script: style.css + Description: Custom styles for Formeleon admin interface. + ============================================================================= +*/ + +/* Global Styles */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f8f9fa; +} + +/* Card Styles */ +.card { + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.card-header { + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.125); +} + +.card-footer { + background-color: rgba(0, 0, 0, 0.03); + border-top: 1px solid rgba(0, 0, 0, 0.125); +} + +/* Badge Styles */ +.badge { + font-weight: 500; + padding: 0.35em 0.65em; + border-radius: 0.25rem; +} + +/* Button Styles */ +.btn { + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.2s; +} + +.btn-outline-* { + border-width: 2px; +} + +/* Form Control Styles */ +.form-control, .form-select { + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + border: 1px solid #ced4da; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-control:focus, .form-select:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.25rem rgba(0, 123, 255, 0.25); +} + +/* Table Styles */ +.table { + margin-bottom: 0; +} + +.table th { + font-weight: 600; + border-bottom: 2px solid #dee2e6; + padding: 0.75rem 1rem; +} + +.table td { + padding: 0.75rem 1rem; + vertical-align: middle; +} + +.table-hover tbody tr:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +/* Dev Mode Banner */ +.dev-mode-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(90deg, #ffc107, #ff8c00); + color: #000; + padding: 12px 20px; + font-size: 0.9rem; + font-weight: 500; + z-index: 1000; + box-shadow: 0 -2px 15px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 10px; +} + +.dev-mode-banner i { + font-size: 1.2rem; +} + +/* NavBar Styles */ +.navbar { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 0.5rem 1rem; +} + +.navbar-brand { + font-weight: 700; + font-size: 1.25rem; +} + +.navbar-nav .nav-link { + padding: 0.5rem 1rem; + font-weight: 500; +} + +.navbar-nav .nav-link:hover { + color: #0d6efd; +} + +.dropdown-menu { + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + border: none; + padding: 0.5rem 0; +} + +.dropdown-item { + padding: 0.5rem 1.5rem; + font-weight: 500; +} + +.dropdown-item:hover, .dropdown-item:focus { + background-color: #f8f9fa; + color: #0d6efd; +} + +/* Input Group Styles */ +.input-group-text { + border-radius: 0.375rem; +} + +/* Alert Styles */ +.alert { + border-radius: 0.375rem; + border: none; + padding: 1rem 1.25rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.alert-heading { + font-weight: 600; +} + +/* Code Blocks */ +code { + background-color: rgba(0, 0, 0, 0.05); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875em; +} + +pre { + background-color: #f8f9fa; + padding: 1rem; + border-radius: 0.375rem; + border: 1px solid #dee2e6; + overflow-x: auto; +} + +/* Utility Classes */ +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.py-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} + +.display-4 { + font-size: calc(1.525rem + 3.3vw); + font-weight: 300; + line-height: 1.2; +} + +/* Responsive Adjustments */ +@media (max-width: 768px) { + .navbar-collapse { + background-color: rgba(0, 0, 0, 0.1); + } + + .dropdown-menu { + position: static; + float: none; + } + + .dev-mode-banner { + flex-direction: column; + text-align: center; + padding: 8px; + font-size: 0.8rem; + } +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Form Builder Specific Styles */ +.form-builder { + background: #fff; + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.form-field { + border: 1px dashed #ced4da; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.375rem; + transition: all 0.2s; +} + +.form-field:hover { + border-color: #80bdff; + background-color: rgba(0, 123, 255, 0.05); +} + +.form-field-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.form-field-actions { + display: flex; + gap: 0.5rem; +} + +/* JSON Editor Styles */ +.json-editor { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 1rem; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + white-space: pre; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +/* Status Badge Colors */ +.bg-success { + background-color: #198754 !important; +} + +.bg-warning { + background-color: #ffc107 !important; +} + +.bg-danger { + background-color: #dc3545 !important; +} + +.bg-primary { + background-color: #0d6efd !important; +} + +.bg-info { + background-color: #0dcaf0 !important; +} + +/* Toolbar Styles */ +.toolbar { + background: #fff; + border-bottom: 1px solid #dee2e6; + padding: 0.75rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.toolbar-group { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Modal Styles */ +.modal-content { + border-radius: 0.5rem; + border: none; +} + +.modal-header { + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.125); +} + +.modal-footer { + background-color: rgba(0, 0, 0, 0.03); + border-top: 1px solid rgba(0, 0, 0, 0.125); +} + +/* Pagination Styles */ +.pagination { + justify-content: center; +} + +.page-item.active .page-link { + background-color: #0d6efd; + border-color: #0d6efd; +} + +.page-link { + color: #0d6efd; + border-radius: 0.375rem; +} + +.page-link:hover { + background-color: #f8f9fa; +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..c9f813d --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,432 @@ +/** ============================================================================= + * Formeleon - Multi-tenant Form Generator + * Author: Björn Runden + * Script: main.js + * Description: Main JavaScript for Formeleon admin interface. + * Handles common functionality, AJAX requests, and UI interactions. + * ============================================================================= + */ + +// Wait for DOM to be ready +$(document).ready(function() { + // Initialize tooltips + $('[data-bs-toggle="tooltip"]').tooltip(); + + // Initialize popovers + $('[data-bs-toggle="popover"]').popover(); + + // Handle auto-dismiss alerts + setTimeout(function() { + $('.alert').alert('close'); + }, 5000); + + // Form field management (for form builder) + initFormBuilder(); + + // JSON editor initialization + initJsonEditor(); + + // Search functionality + initSearch(); + + // Confirmation for delete actions + initDeleteConfirmation(); +}); + +/** + * Initialize form builder functionality + */ +function initFormBuilder() { + // Add field button + $(document).on('click', '.add-field', function(e) { + e.preventDefault(); + const container = $(this).data('container'); + addFieldToContainer(container); + }); + + // Remove field button + $(document).on('click', '.remove-field', function(e) { + e.preventDefault(); + $(this).closest('.form-field').remove(); + }); + + // Field type change handler + $(document).on('change', '.field-type-select', function() { + const fieldDiv = $(this).closest('.form-field'); + const fieldType = $(this).val(); + updateFieldOptions(fieldDiv, fieldType); + }); +} + +/** + * Add a new field to the form builder + * @param {string} container - CSS selector for the container + */ +function addFieldToContainer(container) { + const containerEl = $(container); + const fieldCount = containerEl.find('.form-field').length + 1; + + const fieldHtml = ` +
+
+
Field ${fieldCount}
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
Comma-separated list of options
+
+
+
+ `; + + containerEl.append(fieldHtml); +} + +/** + * Update field options based on field type + * @param {jQuery} fieldDiv - The field div element + * @param {string} fieldType - The selected field type + */ +function updateFieldOptions(fieldDiv, fieldType) { + const optionsGroup = fieldDiv.find('.field-options'); + + // Remove existing options + optionsGroup.remove(); + + // Add type-specific options + switch(fieldType) { + case 'select': + case 'radio': + case 'checkbox': + const optionsHtml = ` +
+
+ + +
One option per line
+
+
+ `; + fieldDiv.append(optionsHtml); + break; + + case 'textarea': + const textareaHtml = ` +
+
+ + +
+
+ + +
+
+ `; + fieldDiv.append(textareaHtml); + break; + + default: + // No additional options for basic field types + break; + } +} + +/** + * Initialize JSON editor for form structure + */ +function initJsonEditor() { + // Pretty print JSON in textarea + $('.json-editor').each(function() { + const textarea = $(this); + try { + const json = JSON.parse(textarea.val()); + textarea.val(JSON.stringify(json, null, 2)); + } catch (e) { + // Not valid JSON, leave as is + } + }); + + // Format JSON button + $(document).on('click', '.format-json', function(e) { + e.preventDefault(); + const target = $(this).data('target'); + const textarea = $(target); + + try { + const json = JSON.parse(textarea.val()); + textarea.val(JSON.stringify(json, null, 2)); + } catch (e) { + alert('Invalid JSON: ' + e.message); + } + }); + + // Minify JSON button + $(document).on('click', '.minify-json', function(e) { + e.preventDefault(); + const target = $(this).data('target'); + const textarea = $(target); + + try { + const json = JSON.parse(textarea.val()); + textarea.val(JSON.stringify(json)); + } catch (e) { + alert('Invalid JSON: ' + e.message); + } + }); +} + +/** + * Initialize search functionality + */ +function initSearch() { + // Table search + $('#searchInput').on('input', function() { + const searchTerm = $(this).val().toLowerCase(); + const table = $(this).closest('.card').find('table tbody tr'); + + table.each(function() { + const text = $(this).text().toLowerCase(); + const visible = text.includes(searchTerm); + $(this).toggle(visible); + }); + }); +} + +/** + * Initialize delete confirmation dialogs + */ +function initDeleteConfirmation() { + // Already handled inline with onsubmit="return confirm(...)" + // This is a fallback for more complex confirmations + $(document).on('click', '.confirm-delete', function(e) { + e.preventDefault(); + const message = $(this).data('confirm-message') || 'Are you sure you want to delete this item?'; + const form = $(this).closest('form'); + + if (confirm(message)) { + form.submit(); + } + }); +} + +/** + * AJAX helper function + * @param {string} url - URL to request + * @param {string} method - HTTP method (GET, POST, PUT, DELETE) + * @param {object} data - Data to send + * @param {function} success - Success callback + * @param {function} error - Error callback + */ +function ajaxRequest(url, method, data, success, error) { + $.ajax({ + url: url, + method: method, + data: data, + contentType: 'application/json', + dataType: 'json', + headers: { + 'X-CSRFToken': getCsrfToken() + }, + success: function(response) { + if (typeof success === 'function') { + success(response); + } + }, + error: function(xhr, status, err) { + if (typeof error === 'function') { + error(xhr, status, err); + } else { + alert('Error: ' + (xhr.responseJSON?.message || err)); + } + } + }); +} + +/** + * Get CSRF token from meta tag or cookie + * @return {string} CSRF token + */ +function getCsrfToken() { + return $('meta[name="csrf-token"]').attr('content') || + getCookie('csrftoken') || + getCookie('csrf_token'); +} + +/** + * Get cookie by name + * @param {string} name - Cookie name + * @return {string} Cookie value or null + */ +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +/** + * Show loading spinner + * @param {string|jQuery} element - Element or selector to add spinner to + */ +function showLoading(element) { + const el = $(element); + el.append(''); + el.attr('disabled', true); +} + +/** + * Hide loading spinner + * @param {string|jQuery} element - Element or selector to remove spinner from + */ +function hideLoading(element) { + const el = $(element); + el.find('.spinner-border').remove(); + el.removeAttr('disabled'); +} + +/** + * Format date for display + * @param {string} dateString - ISO date string + * @return {string} Formatted date + */ +function formatDate(dateString) { + if (!dateString) return 'N/A'; + + const date = new Date(dateString); + return date.toLocaleString(); +} + +/** + * Format bytes for display + * @param {number} bytes - Number of bytes + * @return {string} Formatted size + */ +function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * Generate a random ID + * @param {number} length - Length of ID (default: 8) + * @return {string} Random ID + */ +function generateId(length = 8) { + return Math.random().toString(36).substring(2, 2 + length); +} + +/** + * Copy text to clipboard + * @param {string} text - Text to copy + * @param {function} callback - Optional callback + */ +function copyToClipboard(text, callback) { + navigator.clipboard.writeText(text).then(function() { + if (typeof callback === 'function') { + callback(true); + } + }).catch(function(err) { + if (typeof callback === 'function') { + callback(false, err); + } + }); +} + +/** + * Show toast notification + * @param {string} message - Message to display + * @param {string} type - Type (success, error, warning, info) + */ +function showToast(message, type = 'info') { + const toastId = 'toast-' + Date.now(); + const toastHtml = ` + + `; + + // Create toast container if it doesn't exist + let container = $('.toast-container'); + if (container.length === 0) { + container = $('
'); + $('body').append(container); + } + + container.append(toastHtml); + + const toastElement = $('#' + toastId); + const toast = new bootstrap.Toast(toastElement[0], { autohide: true, delay: 3000 }); + toast.show(); + + // Remove toast after it's hidden + toastElement.on('hidden.bs.toast', function() { + toastElement.remove(); + }); +} + +// Export functions for use in other scripts +window.Formeleon = { + ajaxRequest: ajaxRequest, + showLoading: showLoading, + hideLoading: hideLoading, + formatDate: formatDate, + formatBytes: formatBytes, + generateId: generateId, + copyToClipboard: copyToClipboard, + showToast: showToast, + getCsrfToken: getCsrfToken +}; diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..e67b9d4 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,282 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: admin/dashboard.html + Description: Admin dashboard overview page with statistics and quick access. + ============================================================================== #} + +{% extends "base.html" %} + +{% block title %}Dashboard - Formeleon{% endblock %} + +{% block content %} +
+
+
+

Dashboard

+

Welcome, {{ user.name }}! | Role: {{ user.role.value }}

+
+
+ + {# Statistics Cards #} +
+
+
+
+
+ +
+

{{ tenant_count }}

+

Tenants

+
+
+
+
+
+ +
+
+
+
+ +
+

{{ form_count }}

+

Forms

+
+
+
+
+
+ +
+
+
+
+ +
+

{{ submission_count }}

+

Submissions

+
+
+
+
+
+ +
+
+
+
+ +
+

100%

+

Uptime

+
+
+
+
+
+
+ +
+ {# Recent Submissions #} +
+
+
+
Recent Submissions
+
+
+ {% if recent_submissions %} +
+ + + + + + + + + + + + {% for submission in recent_submissions %} + + + + + + + + {% endfor %} + +
IDFormDateStatusActions
{{ submission.id[:12] }}...{{ submission.form_config.name if submission.form_config else 'N/A' }}{{ submission.created_at.strftime('%Y-%m-%d %H:%M') if submission.created_at else 'N/A' }} + + {{ submission.status.value }} + + + + + +
+
+ {% else %} +
+ +

No recent submissions

+
+ {% endif %} +
+ +
+
+ + {# Recent Forms #} +
+
+
+
Recent Forms
+
+
+ {% if recent_forms %} +
+ + + + + + + + + + + + {% for form in recent_forms %} + + + + + + + + {% endfor %} + +
NameTenantStatusCreatedActions
{{ form.name }}{{ form.tenant.name if form.tenant else 'N/A' }} + + {{ 'Active' if form.is_active else 'Inactive' }} + + {{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }} + + + +
+
+ {% else %} +
+ +

No recent forms

+
+ {% endif %} +
+ +
+
+
+ + {% if user.is_admin() %} + {# Tenants Overview #} +
+
+
+
+
Tenants Overview
+
+
+ {% if tenants %} +
+ + + + + + + + + + + + {% for tenant in tenants %} + + + + + + + + {% endfor %} + +
NameIDStatusFormsActions
{{ tenant.name }}{{ tenant.id }} + + {{ tenant.status.value }} + + {{ tenant.forms|length }} + + + +
+
+ {% else %} +
+ +

No tenants yet

+
+ {% endif %} +
+ +
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/templates/admin/forms/list.html b/templates/admin/forms/list.html new file mode 100644 index 0000000..83ca175 --- /dev/null +++ b/templates/admin/forms/list.html @@ -0,0 +1,131 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: admin/forms/list.html + Description: List all forms with filtering and management options. + ============================================================================== #} + +{% extends "base.html" %} + +{% block title %}Forms - Formeleon{% endblock %} + +{% block content %} +
+
+
+

Forms

+
+ {% if user %} + + Create Form + + {% endif %} +
+
+
+ + {% if forms %} +
+
+
+
+
All Forms ({{ forms|length }})
+
+ + +
+
+
+
+ + + + + + + + + + + + + + {% for form in forms %} + + + + + + + + + + {% endfor %} + +
NameTenantIDStatusSubmissionsCreatedActions
{{ form.name }}{{ form.tenant.name if form.tenant else 'N/A' }}{{ form.id }} + + {{ 'Active' if form.is_active else 'Inactive' }} + + {{ form.submissions|length }}{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }} +
+ + + + {% if is_admin or (user and form.tenant and user in form.tenant.users) %} + + + +
+ +
+ {% endif %} +
+
+
+
+
+
+
+ {% else %} +
+
+
+
+ +

No Forms

+

No forms have been created yet.

+ {% if user %} + + Create First Form + + {% endif %} +
+
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/admin/tenants/create.html b/templates/admin/tenants/create.html new file mode 100644 index 0000000..c4b80f1 --- /dev/null +++ b/templates/admin/tenants/create.html @@ -0,0 +1,110 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: admin/tenants/create.html + Description: Form for creating a new tenant. + ============================================================================== #} + +{% extends "base.html" %} + +{% block title %}Create Tenant - Formeleon{% endblock %} + +{% block content %} +
+
+
+

Create Tenant

+

Add a new tenant to the system.

+
+
+ +
+
+
+
+
Tenant Details
+
+
+
+
+
+ + +
Unique display name for the tenant
+
+ +
+ + +
API key used by client snippets
+
+ +
+ + +
Comma-separated list of allowed domains. Use * for all domains.
+
+ +
+ + +
Initial tenant status
+
+
+
+ + +
+
+
+ +
+
+
+
Information
+
+
+
+
Public Key Usage:
+
Used in client JavaScript snippets for authentication
+ +
Allowed Origins:
+
CORS and handshake validation
+ +
Status:
+
Controls whether tenant can receive submissions
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/admin/tenants/list.html b/templates/admin/tenants/list.html new file mode 100644 index 0000000..4f141f2 --- /dev/null +++ b/templates/admin/tenants/list.html @@ -0,0 +1,112 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: admin/tenants/list.html + Description: List all tenants with filtering and pagination. + ============================================================================== #} + +{% extends "base.html" %} + +{% block title %}Tenants - Formeleon{% endblock %} + +{% block content %} +
+
+
+

Tenants

+
+ {% if is_admin %} + + Create Tenant + + {% endif %} +
+
+
+ + {% if tenants %} +
+
+
+
+
All Tenants ({{ tenants|length }})
+
+ + +
+
+
+
+ + + + + + + + + + + + + + {% for tenant in tenants %} + + + + + + + + + + {% endfor %} + +
NameIDPublic KeyStatusFormsCreatedActions
{{ tenant.name }}{{ tenant.id }}{{ tenant.public_key[:16] }}... + + {{ tenant.status.value }} + + {{ tenant.forms|length }}{{ tenant.created_at.strftime('%Y-%m-%d') if tenant.created_at else 'N/A' }} +
+ + + + {% if is_admin %} + + + +
+ +
+ {% endif %} +
+
+
+
+
+
+
+ {% else %} +
+
+
+
+ +

No Tenants

+

No tenants have been created yet.

+ {% if is_admin %} + + Create First Tenant + + {% endif %} +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/tenants/view.html b/templates/admin/tenants/view.html new file mode 100644 index 0000000..b27ebc6 --- /dev/null +++ b/templates/admin/tenants/view.html @@ -0,0 +1,256 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: admin/tenants/view.html + Description: View and edit tenant details with associated forms. + ============================================================================== #} + +{% extends "base.html" %} + +{% block title %}{{ tenant.name }} - Formeleon{% endblock %} + +{% block content %} +
+
+
+
+

{{ tenant.name }}

+

+ ID: {{ tenant.id }} | + Status: + {{ tenant.status.value }} + +

+
+
+ + Back to Tenants + + {% if is_admin %} + + {% endif %} +
+
+
+ +
+ {# Tenant Details Card #} +
+
+
+
Tenant Details
+
+
+
+
Public Key:
+
+ {{ tenant.public_key }} + +
+ +
Allowed Origins:
+
{{ tenant.allowed_origins }}
+ +
Created:
+
{{ tenant.created_at.strftime('%Y-%m-%d %H:%M:%S') if tenant.created_at else 'N/A' }}
+ +
Last Updated:
+
{{ tenant.updated_at.strftime('%Y-%m-%d %H:%M:%S') if tenant.updated_at else 'N/A' }}
+ +
Configuration:
+
+
{{ tenant.config|tojson(indent=2) if tenant.config else '{}' }}
+
+
+
+
+
+ + {# Tenant Statistics #} +
+
+
+
Statistics
+
+
+
+
+
+
+

{{ forms|length }}

+

Forms

+
+
+
+
+
+
+

{{ tenant.submissions|length }}

+

Submissions

+
+
+
+
+
+
+

+ {% if tenant.users %} + {{ tenant.users|length }} + {% else %} + 0 + {% endif %} +

+

Associated Users

+
+
+
+
+
+
+
+
+ + {# Forms List #} +
+
+
+
+
Forms ({{ forms|length }})
+ + Create Form + +
+
+ {% if forms %} +
+ + + + + + + + + + + + + {% for form in forms %} + + + + + + + + + {% endfor %} + +
NameIDStatusCreatedSubmissionsActions
{{ form.name }}{{ form.id }} + + {{ 'Active' if form.is_active else 'Inactive' }} + + {{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}{{ form.submissions|length }} +
+ + + + {% if is_admin or user in tenant.users %} + + + + {% endif %} +
+
+
+ {% else %} +
+ +

No forms created yet

+ + Create First Form + +
+ {% endif %} +
+
+
+
+
+ +{# Edit Tenant Modal #} +{% if is_admin %} + +{% endif %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d9f0177 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,74 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: base.html + Description: Base template for all HTML pages. Includes Bootstrap 5, jQuery, and common styles. + ============================================================================== #} + + + + + + {% block title %}Formeleon - Form Generator{% endblock %} + + {# Bootstrap 5 CSS #} + + + {# Bootstrap Icons #} + + + {# Custom CSS #} + + + {# Font Awesome for additional icons #} + + + {% block extra_css %}{% endblock %} + + + {# Navigation #} + {% include 'navbar.html' %} + + {# Main Content #} +
+ {% block messages %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + {% endblock %} + + {% block content %}{% endblock %} +
+ + {# Dev Mode Banner #} + {% if dev_mode %} +
+ DEV MODE ENABLED + + User: {{ user.name if user else 'Unknown' }} | + Role: {{ user.role.value if user and user.role else 'N/A' }} + +
+ {% endif %} + + {# jQuery #} + + + {# Bootstrap 5 JS Bundle #} + + + {# Custom JS #} + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b92624e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,126 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: index.html + Description: Simple welcome page for root URL. + ============================================================================== #} + + + + + + + Formeleon - Form Generator + + + + + +
+
+
+ +
+ +

Welcome to Formeleon

+

Multi-tenant Form Generator with Bot Protection

+ +
+ +
+
+
    +
  • Dynamic Form Generation
  • +
  • Multi-Tenant Support
  • +
  • reCAPTCHA v3 Protection
  • +
+
+
+
    +
  • Zitadel OIDC Integration
  • +
  • RESTful API
  • +
  • Easy Embedding
  • +
+
+
+ +
+ + + + {% if dev_mode %} +
+ + Development Mode is enabled +
+ {% endif %} +
+
+ + + + diff --git a/templates/navbar.html b/templates/navbar.html new file mode 100644 index 0000000..4e29a5a --- /dev/null +++ b/templates/navbar.html @@ -0,0 +1,106 @@ +{# ============================================================================== + Formeleon - Multi-tenant Form Generator + Author: Björn Runden + Template: navbar.html + Description: Navigation bar template with responsive design. + ============================================================================== #} + +