initiales bauen mit mistral

This commit is contained in:
Björn Runden committed 2026-08-01 15:40:30 +02:00
1 parent 7e2e8bdf59
commit c74bca1e13
22 files changed
+5642

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
# =============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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
+90
View File
@@ -0,0 +1,90 @@
# =============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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
+53
View File
@@ -0,0 +1,53 @@
# =============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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"]
+482
View File
@@ -0,0 +1,482 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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'])
+176
View File
@@ -0,0 +1,176 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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
}
+83
View File
@@ -0,0 +1,83 @@
# =============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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:
+642
View File
@@ -0,0 +1,642 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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'<Tenant {self.id} - {self.name}>'
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'<FormConfig {self.id} - {self.name}>'
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'<Submission {self.id} - {self.form_config_id}>'
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'<User {self.id} - {self.email} ({self.role.value})>'
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'<AuditLog {self.id} - {self.action} on {self.resource_type}:{self.resource_id}>'
+36
View File
@@ -0,0 +1,36 @@
# =============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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
+758
View File
@@ -0,0 +1,758 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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/<id> - View/edit tenant
GET /admin/forms/ - List all forms
GET /admin/forms/<id> - View/edit form
GET /admin/forms/create - Create new form
GET /admin/submissions/ - List submissions
GET /admin/submissions/<id> - View submission details
GET /admin/users/ - List users
GET /admin/users/<id> - 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/<tenant_id>', 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/<tenant_id>/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/<form_id>', 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/<form_id>/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/<submission_id>')
@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/<submission_id>/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/<user_id>', 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/<user_id>/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))
+512
View File
@@ -0,0 +1,512 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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/<form_id> - Submit form data
GET /api/v1/form/<form_id> - 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/<form_id>', 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/<form_id>', 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/<tenant_id>')
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]
}
})
+440
View File
@@ -0,0 +1,440 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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()
})
+225
View File
@@ -0,0 +1,225 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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
+357
View File
@@ -0,0 +1,357 @@
/* =============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
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;
}
+432
View File
@@ -0,0 +1,432 @@
/** =============================================================================
* Formeleon - Multi-tenant Form Generator
* Author: Björn Runden <br@runden.net>
* 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 = `
<div class="form-field border p-3 mb-3 rounded">
<div class="form-field-header d-flex justify-content-between mb-2">
<h5>Field ${fieldCount}</h5>
<div class="form-field-actions">
<button type="button" class="btn btn-sm btn-outline-danger remove-field">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Field Name</label>
<input type="text" class="form-control" name="fields[${fieldCount}][name]" placeholder="field_name">
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select class="form-select field-type-select" name="fields[${fieldCount}][type]">
<option value="text">Text</option>
<option value="email">Email</option>
<option value="textarea">Textarea</option>
<option value="select">Select</option>
<option value="checkbox">Checkbox</option>
<option value="radio">Radio</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="file">File</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Label</label>
<input type="text" class="form-control" name="fields[${fieldCount}][label]" placeholder="Field Label">
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="fields[${fieldCount}][required]" value="true">
<label class="form-check-label">Required</label>
</div>
</div>
</div>
<div class="row mt-2">
<div class="col-12">
<label class="form-label">Options (for select/radio/checkbox)</label>
<input type="text" class="form-control" name="fields[${fieldCount}][options]" placeholder="option1,option2,option3">
<div class="form-text">Comma-separated list of options</div>
</div>
</div>
</div>
`;
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 = `
<div class="field-options row mt-2">
<div class="col-12">
<label class="form-label">Options</label>
<textarea class="form-control" name="fields[][options]" placeholder="option1\noption2\noption3"></textarea>
<div class="form-text">One option per line</div>
</div>
</div>
`;
fieldDiv.append(optionsHtml);
break;
case 'textarea':
const textareaHtml = `
<div class="field-options row mt-2">
<div class="col-md-6">
<label class="form-label">Rows</label>
<input type="number" class="form-control" name="fields[][rows]" value="3">
</div>
<div class="col-md-6">
<label class="form-label">Placeholder</label>
<input type="text" class="form-control" name="fields[][placeholder]" placeholder="Enter text here...">
</div>
</div>
`;
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('<span class="spinner-border spinner-border-sm ms-2" role="status" aria-hidden="true"></span>');
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 = `
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
// Create toast container if it doesn't exist
let container = $('.toast-container');
if (container.length === 0) {
container = $('<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>');
$('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
};
+282
View File
@@ -0,0 +1,282 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: admin/dashboard.html
Description: Admin dashboard overview page with statistics and quick access.
============================================================================== #}
{% extends "base.html" %}
{% block title %}Dashboard - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12">
<h1><i class="bi bi-speedometer2"></i> Dashboard</h1>
<p class="text-muted">Welcome, <strong>{{ user.name }}</strong>! | Role: <span class="badge bg-{{ 'primary' if user.role.value == 'admin' else 'secondary' }}">{{ user.role.value }}</span></p>
</div>
</div>
{# Statistics Cards #}
<div class="row mb-4">
<div class="col-md-3 col-6 mb-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<i class="bi bi-buildings display-6"></i>
<div class="ms-3">
<h3 class="mb-0">{{ tenant_count }}</h3>
<p class="mb-0">Tenants</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<i class="bi bi-file-earmark-text display-6"></i>
<div class="ms-3">
<h3 class="mb-0">{{ form_count }}</h3>
<p class="mb-0">Forms</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex align-items-center">
<i class="bi bi-inbox display-6"></i>
<div class="ms-3">
<h3 class="mb-0">{{ submission_count }}</h3>
<p class="mb-0">Submissions</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card bg-warning text-dark">
<div class="card-body">
<div class="d-flex align-items-center">
<i class="bi bi-activity display-6"></i>
<div class="ms-3">
<h3 class="mb-0">100%</h3>
<p class="mb-0">Uptime</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
{# Recent Submissions #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-inbox"></i> Recent Submissions</h5>
</div>
<div class="card-body p-0">
{% if recent_submissions %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Form</th>
<th>Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for submission in recent_submissions %}
<tr>
<td>{{ submission.id[:12] }}...</td>
<td>{{ submission.form_config.name if submission.form_config else 'N/A' }}</td>
<td>{{ submission.created_at.strftime('%Y-%m-%d %H:%M') if submission.created_at else 'N/A' }}</td>
<td>
<span class="badge bg-{{ 'success' if submission.status.value == 'processed' else 'warning' if submission.status.value == 'pending' else 'danger' }}">
{{ submission.status.value }}
</span>
</td>
<td>
<a href="{{ url_for('admin.view_submission', submission_id=submission.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox display-4"></i>
<p>No recent submissions</p>
</div>
{% endif %}
</div>
<div class="card-footer bg-light">
<a href="{{ url_for('admin.list_submissions') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-list"></i> View All Submissions
</a>
</div>
</div>
</div>
{# Recent Forms #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-file-earmark-text"></i> Recent Forms</h5>
</div>
<div class="card-body p-0">
{% if recent_forms %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Tenant</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for form in recent_forms %}
<tr>
<td>{{ form.name }}</td>
<td>{{ form.tenant.name if form.tenant else 'N/A' }}</td>
<td>
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
{{ 'Active' if form.is_active else 'Inactive' }}
</span>
</td>
<td>{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}</td>
<td>
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-file-earmark-text display-4"></i>
<p>No recent forms</p>
</div>
{% endif %}
</div>
<div class="card-footer bg-light">
<a href="{{ url_for('admin.list_forms') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-list"></i> View All Forms
</a>
<a href="{{ url_for('admin.create_form') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Create Form
</a>
</div>
</div>
</div>
</div>
{% if user.is_admin() %}
{# Tenants Overview #}
<div class="row">
<div class="col-12 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-buildings"></i> Tenants Overview</h5>
</div>
<div class="card-body p-0">
{% if tenants %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>ID</th>
<th>Status</th>
<th>Forms</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for tenant in tenants %}
<tr>
<td>{{ tenant.name }}</td>
<td><code>{{ tenant.id }}</code></td>
<td>
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
{{ tenant.status.value }}
</span>
</td>
<td>{{ tenant.forms|length }}</td>
<td>
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-buildings display-4"></i>
<p>No tenants yet</p>
</div>
{% endif %}
</div>
<div class="card-footer bg-light">
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-list"></i> View All Tenants
</a>
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Create Tenant
</a>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_css %}
<style>
.dev-mode-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffc107;
color: #000;
padding: 10px 20px;
font-size: 0.9rem;
z-index: 1000;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.display-6 {
font-size: calc(1.2525rem + 0.975vw);
}
</style>
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: admin/forms/list.html
Description: List all forms with filtering and management options.
============================================================================== #}
{% extends "base.html" %}
{% block title %}Forms - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<h1><i class="bi bi-file-earmark-text"></i> Forms</h1>
<div>
{% if user %}
<a href="{{ url_for('admin.create_form') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Create Form
</a>
{% endif %}
</div>
</div>
</div>
{% if forms %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5><i class="bi bi-list"></i> All Forms ({{ forms|length }})</h5>
<div class="input-group" style="width: 300px;">
<input type="text" class="form-control" id="searchInput" placeholder="Search forms...">
<button class="btn btn-outline-secondary" type="button">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Tenant</th>
<th>ID</th>
<th>Status</th>
<th>Submissions</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for form in forms %}
<tr>
<td>{{ form.name }}</td>
<td>{{ form.tenant.name if form.tenant else 'N/A' }}</td>
<td><code>{{ form.id }}</code></td>
<td>
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
{{ 'Active' if form.is_active else 'Inactive' }}
</span>
</td>
<td>{{ form.submissions|length }}</td>
<td>{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-secondary" title="View">
<i class="bi bi-eye"></i>
</a>
{% if is_admin or (user and form.tenant and user in form.tenant.users) %}
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ url_for('admin.delete_form', form_id=form.id) }}" method="POST"
onsubmit="return confirm('Are you sure you want to delete this form? This action cannot be undone.');">
<button type="submit" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-file-earmark-text display-4 text-muted mb-3"></i>
<h3>No Forms</h3>
<p class="text-muted">No forms have been created yet.</p>
{% if user %}
<a href="{{ url_for('admin.create_form') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Create First Form
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Search functionality
$(document).ready(function() {
$('#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);
});
});
});
</script>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: admin/tenants/create.html
Description: Form for creating a new tenant.
============================================================================== #}
{% extends "base.html" %}
{% block title %}Create Tenant - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12">
<h1><i class="bi bi-buildings"></i> Create Tenant</h1>
<p class="text-muted">Add a new tenant to the system.</p>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-pencil"></i> Tenant Details</h5>
</div>
<form method="POST" action="{{ url_for('admin.create_tenant') }}">
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="name" class="form-label">Tenant Name *</label>
<input type="text" class="form-control" id="name" name="name" required placeholder="My Company">
<div class="form-text">Unique display name for the tenant</div>
</div>
<div class="col-md-6">
<label for="public_key" class="form-label">Public Key *</label>
<input type="text" class="form-control" id="public_key" name="public_key" required
placeholder="auto-generated-if-empty">
<div class="form-text">API key used by client snippets</div>
</div>
<div class="col-12">
<label for="allowed_origins" class="form-label">Allowed Origins *</label>
<input type="text" class="form-control" id="allowed_origins" name="allowed_origins"
value="*" required placeholder="https://example.com,https://other.com">
<div class="form-text">Comma-separated list of allowed domains. Use * for all domains.</div>
</div>
<div class="col-md-6">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="active" selected>Active</option>
<option value="suspended">Suspended</option>
<option value="inactive">Inactive</option>
</select>
<div class="form-text">Initial tenant status</div>
</div>
</div>
</div>
<div class="card-footer bg-light d-flex justify-content-end">
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-outline-secondary me-2">
<i class="bi bi-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check"></i> Create Tenant
</button>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-info-circle"></i> Information</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-6">Public Key Usage:</dt>
<dd class="col-sm-6">Used in client JavaScript snippets for authentication</dd>
<dt class="col-sm-6">Allowed Origins:</dt>
<dd class="col-sm-6">CORS and handshake validation</dd>
<dt class="col-sm-6">Status:</dt>
<dd class="col-sm-6">Controls whether tenant can receive submissions</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Auto-generate public key if empty
$(document).ready(function() {
const publicKeyInput = $('#public_key');
// Generate a random public key if empty on page load
if (!publicKeyInput.val().trim()) {
publicKeyInput.val('pub_' + Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15));
}
});
</script>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: admin/tenants/list.html
Description: List all tenants with filtering and pagination.
============================================================================== #}
{% extends "base.html" %}
{% block title %}Tenants - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<h1><i class="bi bi-buildings"></i> Tenants</h1>
<div>
{% if is_admin %}
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Create Tenant
</a>
{% endif %}
</div>
</div>
</div>
{% if tenants %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5><i class="bi bi-list"></i> All Tenants ({{ tenants|length }})</h5>
<div class="input-group" style="width: 300px;">
<input type="text" class="form-control" id="searchInput" placeholder="Search tenants...">
<button class="btn btn-outline-secondary" type="button">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>ID</th>
<th>Public Key</th>
<th>Status</th>
<th>Forms</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for tenant in tenants %}
<tr>
<td>{{ tenant.name }}</td>
<td><code>{{ tenant.id }}</code></td>
<td><code class="text-truncate" style="max-width: 200px;" title="{{ tenant.public_key }}">{{ tenant.public_key[:16] }}...</code></td>
<td>
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
{{ tenant.status.value }}
</span>
</td>
<td>{{ tenant.forms|length }}</td>
<td>{{ tenant.created_at.strftime('%Y-%m-%d') if tenant.created_at else 'N/A' }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-outline-secondary" title="View">
<i class="bi bi-eye"></i>
</a>
{% if is_admin %}
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ url_for('admin.delete_tenant', tenant_id=tenant.id) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this tenant? This action cannot be undone.');">
<button type="submit" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-buildings display-4 text-muted mb-3"></i>
<h3>No Tenants</h3>
<p class="text-muted">No tenants have been created yet.</p>
{% if is_admin %}
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Create First Tenant
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+256
View File
@@ -0,0 +1,256 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
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 %}
<div class="container">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<div>
<h1><i class="bi bi-buildings"></i> {{ tenant.name }}</h1>
<p class="text-muted mb-0">
ID: <code>{{ tenant.id }}</code> |
Status: <span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
{{ tenant.status.value }}
</span>
</p>
</div>
<div>
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Tenants
</a>
{% if is_admin %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#editTenantModal">
<i class="bi bi-pencil"></i> Edit
</button>
{% endif %}
</div>
</div>
</div>
<div class="row">
{# Tenant Details Card #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-info-circle"></i> Tenant Details</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Public Key:</dt>
<dd class="col-sm-8">
<code class="text-break">{{ tenant.public_key }}</code>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('{{ tenant.public_key }}', function() { alert('Copied to clipboard!'); })">
<i class="bi bi-clipboard"></i>
</button>
</dd>
<dt class="col-sm-4">Allowed Origins:</dt>
<dd class="col-sm-8">{{ tenant.allowed_origins }}</dd>
<dt class="col-sm-4">Created:</dt>
<dd class="col-sm-8">{{ tenant.created_at.strftime('%Y-%m-%d %H:%M:%S') if tenant.created_at else 'N/A' }}</dd>
<dt class="col-sm-4">Last Updated:</dt>
<dd class="col-sm-8">{{ tenant.updated_at.strftime('%Y-%m-%d %H:%M:%S') if tenant.updated_at else 'N/A' }}</dd>
<dt class="col-sm-4">Configuration:</dt>
<dd class="col-sm-8">
<pre class="mb-0 bg-light p-2 rounded">{{ tenant.config|tojson(indent=2) if tenant.config else '{}' }}</pre>
</dd>
</dl>
</div>
</div>
</div>
{# Tenant Statistics #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-graph-up"></i> Statistics</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 col-md-3 mb-3">
<div class="card bg-light h-100">
<div class="card-body">
<h3 class="mb-0">{{ forms|length }}</h3>
<p class="text-muted mb-0">Forms</p>
</div>
</div>
</div>
<div class="col-6 col-md-3 mb-3">
<div class="card bg-light h-100">
<div class="card-body">
<h3 class="mb-0">{{ tenant.submissions|length }}</h3>
<p class="text-muted mb-0">Submissions</p>
</div>
</div>
</div>
<div class="col-12 col-md-6 mb-3">
<div class="card bg-light h-100">
<div class="card-body">
<h3 class="mb-0">
{% if tenant.users %}
{{ tenant.users|length }}
{% else %}
0
{% endif %}
</h3>
<p class="text-muted mb-0">Associated Users</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{# Forms List #}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5><i class="bi bi-file-earmark-text"></i> Forms ({{ forms|length }})</h5>
<a href="{{ url_for('admin.create_form') }}?tenant_id={{ tenant.id }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Create Form
</a>
</div>
<div class="card-body p-0">
{% if forms %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>ID</th>
<th>Status</th>
<th>Created</th>
<th>Submissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for form in forms %}
<tr>
<td>{{ form.name }}</td>
<td><code>{{ form.id }}</code></td>
<td>
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
{{ 'Active' if form.is_active else 'Inactive' }}
</span>
</td>
<td>{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}</td>
<td>{{ form.submissions|length }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-secondary" title="View">
<i class="bi bi-eye"></i>
</a>
{% if is_admin or user in tenant.users %}
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-file-earmark-text display-4"></i>
<p>No forms created yet</p>
<a href="{{ url_for('admin.create_form') }}?tenant_id={{ tenant.id }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Create First Form
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{# Edit Tenant Modal #}
{% if is_admin %}
<div class="modal fade" id="editTenantModal" tabindex="-1" aria-labelledby="editTenantModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editTenantModalLabel"><i class="bi bi-pencil"></i> Edit Tenant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}">
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label for="edit_name" class="form-label">Tenant Name *</label>
<input type="text" class="form-control" id="edit_name" name="name"
value="{{ tenant.name }}" required>
</div>
<div class="col-md-6">
<label for="edit_public_key" class="form-label">Public Key *</label>
<input type="text" class="form-control" id="edit_public_key" name="public_key"
value="{{ tenant.public_key }}" required>
</div>
<div class="col-12">
<label for="edit_allowed_origins" class="form-label">Allowed Origins *</label>
<input type="text" class="form-control" id="edit_allowed_origins" name="allowed_origins"
value="{{ tenant.allowed_origins }}" required>
<div class="form-text">Comma-separated list of allowed domains</div>
</div>
<div class="col-md-6">
<label for="edit_status" class="form-label">Status</label>
<select class="form-select" id="edit_status" name="status">
<option value="active" {% if tenant.status.value == 'active' %}selected{% endif %}>Active</option>
<option value="suspended" {% if tenant.status.value == 'suspended' %}selected{% endif %}>Suspended</option>
<option value="inactive" {% if tenant.status.value == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
<i class="bi bi-x"></i> Cancel
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Copy to clipboard function
function copyToClipboard(text, callback) {
navigator.clipboard.writeText(text).then(function() {
if (typeof callback === 'function') {
callback();
}
}).catch(function(err) {
console.error('Failed to copy: ', err);
});
}
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: base.html
Description: Base template for all HTML pages. Includes Bootstrap 5, jQuery, and common styles.
============================================================================== #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Formeleon - Form Generator{% endblock %}</title>
{# Bootstrap 5 CSS #}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
{# Bootstrap Icons #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
{# Custom CSS #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{# Font Awesome for additional icons #}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
{% block extra_css %}{% endblock %}
</head>
<body>
{# Navigation #}
{% include 'navbar.html' %}
{# Main Content #}
<main class="container-fluid">
{% block messages %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-3">
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}
{% block content %}{% endblock %}
</main>
{# Dev Mode Banner #}
{% if dev_mode %}
<div class="dev-mode-banner">
<i class="bi bi-bug-fill"></i> DEV MODE ENABLED
<span class="float-end">
User: {{ user.name if user else 'Unknown' }} |
Role: {{ user.role.value if user and user.role else 'N/A' }}
</span>
</div>
{% endif %}
{# jQuery #}
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
{# Bootstrap 5 JS Bundle #}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{# Custom JS #}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: index.html
Description: Simple welcome page for root URL.
============================================================================== #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Formeleon - Form Generator</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.welcome-container {
max-width: 800px;
margin: 0 auto;
padding: 4rem 2rem;
}
.welcome-card {
background: white;
border-radius: 1rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
padding: 3rem;
}
.welcome-card h1 {
color: #333;
font-weight: 700;
margin-bottom: 1rem;
}
.welcome-card p {
color: #666;
font-size: 1.1rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
font-weight: 600;
}
.feature-list {
list-style: none;
padding: 0;
}
.feature-list li {
padding: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.feature-list i {
color: #667eea;
font-size: 1.25rem;
}
</style>
</head>
<body>
<div class="welcome-container">
<div class="welcome-card text-center">
<div class="mb-4">
<i class="bi bi-file-earmark-text-fill" style="font-size: 4rem; color: #667eea;"></i>
</div>
<h1>Welcome to Formeleon</h1>
<p class="lead">Multi-tenant Form Generator with Bot Protection</p>
<hr class="my-4">
<div class="row mb-4">
<div class="col-md-6 mb-3">
<ul class="feature-list text-start">
<li><i class="bi bi-check-circle-fill"></i> Dynamic Form Generation</li>
<li><i class="bi bi-check-circle-fill"></i> Multi-Tenant Support</li>
<li><i class="bi bi-check-circle-fill"></i> reCAPTCHA v3 Protection</li>
</ul>
</div>
<div class="col-md-6 mb-3">
<ul class="feature-list text-start">
<li><i class="bi bi-check-circle-fill"></i> Zitadel OIDC Integration</li>
<li><i class="bi bi-check-circle-fill"></i> RESTful API</li>
<li><i class="bi bi-check-circle-fill"></i> Easy Embedding</li>
</ul>
</div>
</div>
<hr class="my-4">
<div class="d-grid gap-3">
<a href="/admin/" class="btn btn-primary btn-lg">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<a href="/admin/forms/" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-file-earmark-text"></i> Manage Forms
</a>
<a href="/api/v1/status" class="btn btn-outline-info btn-lg">
<i class="bi bi-check-circle"></i> API Status
</a>
</div>
{% if dev_mode %}
<div class="mt-4 p-3 bg-warning bg-opacity-10 rounded">
<i class="bi bi-bug-fill text-warning"></i>
<strong>Development Mode</strong> is enabled
</div>
{% endif %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
{# ==============================================================================
Formeleon - Multi-tenant Form Generator
Author: Björn Runden <br@runden.net>
Template: navbar.html
Description: Navigation bar template with responsive design.
============================================================================== #}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark" style="margin-bottom: 20px;">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
<i class="bi bi-file-earmark-text-fill"></i> Formeleon
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{# Dashboard #}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
{# Tenants #}
{% if user and user.is_admin() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="tenantsDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-buildings"></i> Tenants
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.list_tenants') }}">All Tenants</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.create_tenant') }}">Create Tenant</a></li>
</ul>
</li>
{% endif %}
{# Forms #}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="formsDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-file-earmark-text"></i> Forms
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.list_forms') }}">All Forms</a></li>
{% if user %}
<li><a class="dropdown-item" href="{{ url_for('admin.create_form') }}">Create Form</a></li>
{% endif %}
</ul>
</li>
{# Submissions #}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.list_submissions') }}">
<i class="bi bi-inbox"></i> Submissions
</a>
</li>
{# Users (Admin only) #}
{% if user and user.is_admin() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="usersDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-people"></i> Users
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.list_users') }}">All Users</a></li>
</ul>
</li>
{% endif %}
{# Settings #}
{% if user and user.is_admin() %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.settings') }}">
<i class="bi bi-gear"></i> Settings
</a>
</li>
{% endif %}
</ul>
{# Right-side navigation #}
<ul class="navbar-nav ms-auto">
{% if user %}
{# User dropdown #}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ user.name }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('admin.profile') }}"><i class="bi bi-person"></i> Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i class="bi bi-box-arrow-right"></i> Logout</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">
<i class="bi bi-box-arrow-in-right"></i> Login
</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>